From 24887ef707997a2bf880594fd56305b189e3e99c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:39:40 +0200 Subject: [PATCH 1/3] feat(DEP0157): add codemod for stream implementation to callback based implementations --- .github/workflows/code-quality.yml | 4 +- .github/workflows/codemod_publish.yml | 2 +- .github/workflows/dir-organisation.yml | 2 +- .github/workflows/lint-workflows.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/refresh-learn.yml | 2 +- .github/workflows/workflow-tests.yml | 2 +- .../stream-thenable-to-callback/codemod.yaml | 23 + .../stream-thenable-to-callback/package.json | 13 + .../src/workflow.ts | 644 ++++++++++++++++++ .../tests/async-esm/expected.js | 9 + .../tests/async-esm/input.js | 8 + .../tests/async-final/expected.js | 13 + .../tests/async-final/input.js | 12 + .../tests/async-flush/expected.js | 13 + .../tests/async-flush/input.js | 12 + .../tests/async-multiple/expected.js | 10 + .../tests/async-multiple/input.js | 9 + .../tests/async-promisify-pattern/expected.js | 9 + .../tests/async-promisify-pattern/input.js | 12 + .../tests/async-read/expected.js | 9 + .../tests/async-read/input.js | 8 + .../tests/async-transform/expected.js | 9 + .../tests/async-transform/input.js | 8 + .../tests/async-within-class/expected.js | 13 + .../tests/async-within-class/input.js | 13 + .../tests/async-write/expected.js | 9 + .../tests/async-write/input.js | 8 + .../stream-thenable-to-callback/workflow.yaml | 25 + 29 files changed, 897 insertions(+), 8 deletions(-) create mode 100644 recipes/stream-thenable-to-callback/codemod.yaml create mode 100644 recipes/stream-thenable-to-callback/package.json create mode 100644 recipes/stream-thenable-to-callback/src/workflow.ts create mode 100644 recipes/stream-thenable-to-callback/tests/async-esm/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-esm/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-final/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-final/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-flush/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-flush/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-multiple/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-multiple/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-promisify-pattern/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-promisify-pattern/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-read/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-read/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-transform/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-transform/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-within-class/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-within-class/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-write/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-write/input.js create mode 100644 recipes/stream-thenable-to-callback/workflow.yaml diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index bfb38537..b454cdae 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit @@ -72,7 +72,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit diff --git a/.github/workflows/codemod_publish.yml b/.github/workflows/codemod_publish.yml index c74572f2..03bf1d88 100644 --- a/.github/workflows/codemod_publish.yml +++ b/.github/workflows/codemod_publish.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: block allowed-endpoints: > diff --git a/.github/workflows/dir-organisation.yml b/.github/workflows/dir-organisation.yml index a826421b..99f89a78 100644 --- a/.github/workflows/dir-organisation.yml +++ b/.github/workflows/dir-organisation.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml index 79e313d4..7b2deb14 100644 --- a/.github/workflows/lint-workflows.yml +++ b/.github/workflows/lint-workflows.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c50fb733..89e0e957 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit diff --git a/.github/workflows/refresh-learn.yml b/.github/workflows/refresh-learn.yml index e8e391ac..e965036e 100644 --- a/.github/workflows/refresh-learn.yml +++ b/.github/workflows/refresh-learn.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit diff --git a/.github/workflows/workflow-tests.yml b/.github/workflows/workflow-tests.yml index fcac97e7..11ea694b 100644 --- a/.github/workflows/workflow-tests.yml +++ b/.github/workflows/workflow-tests.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 with: egress-policy: audit diff --git a/recipes/stream-thenable-to-callback/codemod.yaml b/recipes/stream-thenable-to-callback/codemod.yaml new file mode 100644 index 00000000..21fba744 --- /dev/null +++ b/recipes/stream-thenable-to-callback/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/stream-thenable-to-callback" +version: 1.0.0 +description: "Replace async stream implementation methods with callbacks (DEP0157)." +author: Harshal_25 +license: MIT +workflow: workflow.yaml +category: migration +repository: https://github.com/nodejs/userland-migrations + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - nodejs + +registry: + access: public + visibility: public diff --git a/recipes/stream-thenable-to-callback/package.json b/recipes/stream-thenable-to-callback/package.json new file mode 100644 index 00000000..253ee59a --- /dev/null +++ b/recipes/stream-thenable-to-callback/package.json @@ -0,0 +1,13 @@ +{ + "name": "@nodejs/stream-thenable-to-callback", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.3" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/stream-thenable-to-callback/src/workflow.ts b/recipes/stream-thenable-to-callback/src/workflow.ts new file mode 100644 index 00000000..619b6b1c --- /dev/null +++ b/recipes/stream-thenable-to-callback/src/workflow.ts @@ -0,0 +1,644 @@ +import type { Edit, 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'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import { detectIndentUnit, getLineIndent } from '@nodejs/codemod-utils/ast-grep/indent'; + +const STREAM_CLASSES = [ + 'Writable', 'Readable', 'Transform', 'Duplex', 'PassThrough', +]; + +const OPTION_METHODS = new Set([ + 'write', 'writev', 'read', 'transform', 'flush', 'final', +]); + +const CLASS_METHODS = new Set([ + '_write', '_writev', '_read', '_transform', '_flush', '_final', +]); + +const READ_METHODS = new Set(['read', '_read']); + +const FUNCTION_KINDS = new Set([ + 'method_definition', 'function_expression', 'function', + 'function_declaration', 'arrow_function', 'generator_function', + 'generator_function_declaration', +]); + +const FUNCTION_VALUE_KINDS = new Set([ + 'function_expression', 'function', 'arrow_function', +]); + +const CHAINABLE_KINDS = new Set([ + 'call_expression', 'member_expression', 'subscript_expression', + 'identifier', 'parenthesized_expression', +]); + +const NEVER_THENABLE_KINDS = new Set([ + 'null', 'undefined', 'true', 'false', 'number', 'string', + 'template_string', 'regex', 'array', 'arrow_function', + 'function_expression', 'function', 'class', +]); + +const ARROW_BODY_NEEDS_PARENS = new Set(['object', 'sequence_expression']); + +type StreamFunction = { fn: SgNode; name: string; }; + +type Step = { + statement: SgNode; + argument: SgNode; + name: SgNode | null; +}; + +type ExtractedBody = { + statements: SgNode[]; + comments: SgNode[]; + catchParam: string | null; + catchStatement: SgNode | null; +}; + +type Replacement = { start: number; end: number; text: string }; + +type FileContext = { + source: string; + eol: string; + indentUnit: string; +}; + +/** + * Transform function that converts async stream implementation methods to the + * callback form the streams API expects. + * + * See DEP0157: https://nodejs.org/api/deprecations.html#DEP0157 + * + * Handles: + * 1. async write(chunk, enc, cb) { await a(chunk); cb(); } → a(chunk).then(() => cb()).catch(err => cb(err)) + * 2. Sequential awaits → one .then() link per await, value passed to the next + * 3. async read(size) { this.push(await d()); } → d().then(v => this.push(v)).catch(err => this.destroy(err)) + * 4. try { await a(); cb(); } catch (err) { cb(err); } → a().then(() => cb()).catch(err => cb(err)) + * 5. write: async function () {} / write: async () => {} (options properties) + * 6. _write/_writev/_read/_transform/_flush/_final on classes extending a stream + * 7. Promise return annotations narrowed to void (TypeScript) + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + + // Leverage the utils to find exactly what node:stream exposes here + const streamNames = getStreamClassNames(root); + if (!streamNames.size) return null; + + const source = rootNode.text(); + const context: FileContext = { + source, + eol: source.includes('\r\n') ? '\r\n' : '\n', + indentUnit: detectIndentUnit(source), + }; + + const edits: Edit[] = []; + const edited: Array<[number, number]> = []; + + for (const target of findStreamFunctions(rootNode, streamNames)) { + const { start, end } = rangeOf(target.fn); + + if (edited.some(([s, e]) => start < e && s < end)) continue; + + const edit = transformStream(target, context); + if (!edit) continue; + + edits.push(edit); + edited.push([start, end]); + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +//find stream classes only +function getStreamClassNames(root: SgRoot): Set { + const names = new Set(); + + for (const statement of getModuleDependencies(root, 'stream')) { + for (const className of STREAM_CLASSES) { + const localPath = resolveBindingPath(statement, `$.${className}`); + if (localPath) names.add(localPath); + } + } + + return names; +} + +//find methods in the stream classes +function findStreamFunctions(rootNode: SgNode, streamNames: Set,): StreamFunction[] { + const found: StreamFunction[] = []; + const methods = rootNode.findAll({ rule: { kind: 'method_definition' } }); + + for (const method of methods) { + const name = method.field('name')?.text(); + if (!name) continue; + + if (CLASS_METHODS.has(name) && extendsStream(method, streamNames)) { + found.push({ fn: method, name }); + } else if ( + OPTION_METHODS.has(name) && + isStreamOptionsMember(method, streamNames) + ) { + found.push({ fn: method, name }); + } + } + + const pairs = rootNode.findAll({ rule: { kind: 'pair' } }); + + //check if key -> name & value -> function expression + for (const pair of pairs) { + const key = pair.field('key'); + const value = pair.field('value'); + if (!key || !value) continue; + + const name = key.text().replace(/^(['"])(.*)\1$/, '$2'); + + if ( + OPTION_METHODS.has(name) && + FUNCTION_VALUE_KINDS.has(value.kind()) && + isStreamOptionsMember(pair, streamNames) + ) { + found.push({ fn: value, name }); + } + } + + return found; +} + +//check if a class method belongs to a class that extends one of stream classes +function extendsStream(member: SgNode, streamNames: Set): boolean { + const classBody = member.parent(); + if (classBody?.kind() !== 'class_body') return false; + + const classNode = classBody.parent(); + if (!classNode) return false; + + const heritage = classNode.find({ rule: { kind: 'class_heritage' } }); + if (!heritage) return false; + + const parts = heritage.children().filter((c) => c.kind() !== 'extends'); + const superclass = parts[parts.length - 1]; + + return !!superclass && streamNames.has(withoutSpaces(superclass.text())); +} + +// Checks if an object method property belongs to options passed +function isStreamOptionsMember(member: SgNode, streamNames: Set,): boolean { + const object = member.parent(); + if (object?.kind() !== 'object') return false; + + const args = object.parent(); + if (args?.kind() !== 'arguments') return false; + + const newExpression = args.parent(); + if (newExpression?.kind() !== 'new_expression') return false; + + const ctor = newExpression.field('constructor'); + if (!ctor) return false; + + return streamNames.has(withoutSpaces(ctor.text())); +} + +//main transromation is done here. +function transformStream({ fn, name }: StreamFunction, context: FileContext,): Edit | null { + const fnChildren = fn.children(); + + const asyncToken = fnChildren.find((child) => child.kind() === 'async'); + if (!asyncToken) return null; + + if (fnChildren.some((child) => child.kind() === '*')) return null; + + const body = fn.field('body'); + if (body?.kind() !== 'statement_block') return null; + + const awaits = body + .findAll({ rule: { kind: 'await_expression' } }) + .filter((node) => belongsTo(node, fn)); + + if (!awaits.length) return null; + + const isRead = READ_METHODS.has(name); + const callbackName = isRead ? null : getCallbackParamName(fn); + + if (!isRead && !callbackName) return null; + const extracted = extractBody(body); + if (!extracted) return null; + + const { statements, comments, catchParam, catchStatement } = extracted; + if (statements.length < 2) return null; + + const steps: Step[] = []; + + for (const statement of statements.slice(0, -1)) { + const step = getStep(statement); + if (!step) return null; + + steps.push(step); + } + + if (steps.length !== awaits.length) return null; + + const first = steps[0].argument; + if (NEVER_THENABLE_KINDS.has(first.kind())) return null; + + if (first.text().includes('?.')) return null; + + if (callbackName) { + const rebindsCallback = steps.some((step) => + step.name && getBoundNames(step.name).includes(callbackName) + ); + + if (rebindsCallback) return null; + } + + const finalStatement = statements[statements.length - 1]; + + const finalCall = isRead ? getPushCall(finalStatement) + : getCallbackCall(finalStatement, callbackName as string); + + if (!finalCall) return null; + + const errorName = catchParam ?? 'err'; + const errorHandler = isRead + ? `this.destroy(${errorName})` + : `${callbackName}(${errorName})`; + + if (catchStatement && !isCallStatement(catchStatement, errorHandler)) { + return null; + } + + if (!hasSafeScoping(steps, finalStatement)) return null; + + // Inlined indentation logic using the provided getLineIndent util + const bodyRange = rangeOf(body); + const statementStart = rangeOf(topLevelIn(body, statements[0])).start; + const closingIndent = getLineIndent(context.source, bodyRange.end - 1); + + const sliceStr = context.source.slice( + Math.min(bodyRange.start, statementStart), + Math.max(bodyRange.start, statementStart) + ); + + const isOnSameLine = !sliceStr.includes('\n'); + + let bodyIndent: string; + let unit: string; + + if (!isOnSameLine) { + bodyIndent = getLineIndent(context.source, statementStart); + unit = (bodyIndent.length > closingIndent.length && bodyIndent.startsWith(closingIndent)) + ? bodyIndent.slice(closingIndent.length) + : context.indentUnit; + } else { + unit = context.indentUnit; + bodyIndent = closingIndent + unit; + } + + const chainIndent = bodyIndent + unit; + + const lines: string[] = comments.map( + (c) => `${bodyIndent}${c.text().trim()}`, + ); + + lines.push(`${bodyIndent}${asChainTarget(first)}`); + + for (let i = 1; i <= steps.length; i++) { + const previous = steps[i - 1]; + const isLast = i === steps.length; + const consumer = isLast ? finalStatement : steps[i].statement; + + const arrowParam = getArrowParameter(previous, consumer); + const arrowBody = isLast + ? finalCall + : asArrowBody(steps[i].argument); + + lines.push(`${chainIndent}.then(${arrowParam} => ${arrowBody})`); + } + + lines.push(`${chainIndent}.catch(${errorName} => ${errorHandler});`); + + const { eol } = context; + const replacements: Replacement[] = [ + { + ...bodyRange, + text: `{${eol}${lines.join(eol)}${eol}${closingIndent}}`, + }, + removeToken(asyncToken, context.source), + ]; + + const returnType = fn.field('return_type'); + + if (returnType) { + const replaced = replacePromiseReturnType(returnType); + if (replaced) replacements.push(replaced); + } + return fn.replace(applyReplacements(fn, replacements)); +} + +//extract body and catch information +function extractBody(body: SgNode): ExtractedBody | null { + const statements = getStatements(body); + const comments = getComments(body); + + if (statements.length === 1 && statements[0].kind() === 'try_statement') { + const tryStmt = statements[0]; + + if (tryStmt.field('finalizer')) { + return null; + } + + const tryBlock = tryStmt.field('body'); + const handler = tryStmt.field('handler'); + + if (!tryBlock || !handler) return null; + + const parameter = handler.field('parameter'); + + if (parameter?.kind() !== 'identifier') return null; + + const handlerBody = handler.field('body'); + if (!handlerBody) return null; + + const handlerStatements = getStatements(handlerBody); + if (handlerStatements.length !== 1) return null; + + return { + statements: getStatements(tryBlock), + comments: [ + ...comments, + ...getComments(tryBlock), + ...getComments(handlerBody) + ], + catchParam: parameter.text(), + catchStatement: handlerStatements[0], + }; + } + return { statements, comments, catchParam: null, catchStatement: null }; +} + +//find the awaited expression and variable assigned to it +function getStep(statement: SgNode): Step | null { + let awaitedNode: SgNode | null = null; + let boundName: SgNode | null = null; + const kind = statement.kind(); + + if (kind === 'expression_statement') { + awaitedNode = statement.child(0) ?? null; + } else if (kind === 'lexical_declaration' || kind === 'variable_declaration') { + const decls = statement + .children() + .filter((child) => child.kind() === 'variable_declarator'); + + if (decls.length !== 1) return null; + + awaitedNode = decls[0].field('value') ?? null; + boundName = decls[0].field('name') ?? null; + } + if (awaitedNode?.kind() !== 'await_expression') return null; + const argument = awaitedNode + .children() + .find((c) => c.kind() !== 'await' && c.kind() !== 'comment'); + + if (!argument) return null; + return { statement, argument, name: boundName }; +} + +//get callback parameter from function +function getCallbackParamName(fn: SgNode): string | null { + const params = fn.field('parameters'); + if (!params) return null; + + const list = params + .children() + .filter((c) => !['(', ')', ',', 'comment'].includes(c.kind())); + + const last = list[list.length - 1]; + if (!last) return null; + + const kind = last.kind(); + + if (kind === 'identifier') return last.text(); + + if (kind === 'required_parameter' || kind === 'optional_parameter') { + const pattern = last.child(0); + return pattern?.kind() === 'identifier' ? pattern.text() : null; + } + return null; +} + + +//removes spaces from sourceText +const withoutSpaces = (text: string) => text.replace(/\s+/g, ''); + +//get call expression from statement +function getStatementCall(statement: SgNode): SgNode | null { + const kind = statement.kind(); + + if (kind !== 'expression_statement' && kind !== 'return_statement') { + return null; + } + + const call = statement + .children() + .find((c) => !['return', ';', 'comment'].includes(c.kind())); + + return call?.kind() === 'call_expression' ? call : null; +} + +//check if call uses expected callee +function getCallWithCallee( + statement: SgNode, + matches: (callee: SgNode) => boolean, +): string | null { + const call = getStatementCall(statement); + const callee = call?.field('function'); + + return call && callee && matches(callee) ? call.text() : null; +} + +//find callback call from statement +const getCallbackCall = (statement: SgNode, cbName: string) => + getCallWithCallee( + statement, + (callee) => callee.kind() === 'identifier' && callee.text() === cbName, + ); + +//find this.push call from statement +const getPushCall = (statement: SgNode) => + statement.kind() === 'expression_statement' + ? getCallWithCallee( + statement, + (callee) => withoutSpaces(callee.text()) === 'this.push', + ) + : null; + +//check if statement is expected call +function isCallStatement(statement: SgNode, expected: string): boolean { + if (statement.kind() !== 'expression_statement') return false; + + const call = getStatementCall(statement); + + return !!call && withoutSpaces(call.text()) === withoutSpaces(expected); +} + +//check if variables are safely scoped after conversion +function hasSafeScoping(steps: Step[], finalStatement: SgNode): boolean { + const consumers = [ + ...steps.slice(1).map((s) => s.statement), + finalStatement, + ]; + + for (let i = 0; i < steps.length; i++) { + const name = steps[i].name; + if (!name) continue; + + const bound = getBoundNames(name); + + for (const later of consumers.slice(i + 1)) { + if (referencesAny(later, bound)) return false; + } + } + + return true; +} + +//get variables bound by a node +function getBoundNames(name: SgNode): string[] { + if (name.kind() === 'identifier') return [name.text()]; + + return name + .findAll({ + rule: { + any: [ + { kind: 'identifier' }, + { kind: 'shorthand_property_identifier_pattern' }, + ], + }, + }) + .map((node) => node.text()); +} + +//check if node references any of the supplied names +function referencesAny(node: SgNode, names: string[]): boolean { + return node + .findAll({ + rule: { + any: [ + { kind: 'identifier' }, + { kind: 'shorthand_property_identifier' }, + ], + }, + }) + .some((id) => names.includes(id.text())); +} + +//get parameter for generated then callback +function getArrowParameter(step: Step, consumer: SgNode): string { + if (!step.name) return '()'; + + if (!referencesAny(consumer, getBoundNames(step.name))) { + return '()'; + } + + if (step.name.kind() === 'identifier') { + return step.name.text(); + } + return `(${step.name.text()})`; +} + +//convert expression into valid chain target +function asChainTarget(node: SgNode): string { + return CHAINABLE_KINDS.has(node.kind()) ? node.text() : `(${node.text()})`; +} + +//convert expression into valid arrow body +function asArrowBody(node: SgNode): string { + return ARROW_BODY_NEEDS_PARENS.has(node.kind()) + ? `(${node.text()})` + : node.text(); +} + +//finds who's the parent of passed node. +function belongsTo(node: SgNode, fn: SgNode): boolean { + let current = node.parent(); + + while (current) { + if (FUNCTION_KINDS.has(current.kind())) return sameRange(current, fn); + + current = current.parent(); + } + + return false; +} + +//get direct statements from block +function getStatements(block: SgNode): SgNode[] { + return block + .children() + .filter((c) => !['{', '}', 'comment'].includes(c.kind())); +} + +//get comments directly inside block +function getComments(block: SgNode): SgNode[] { + return block.children().filter((c) => c.kind() === 'comment'); +} + +//get source range of node +function rangeOf(node: SgNode): { start: number; end: number } { + const range = node.range(); + + return { start: range.start.index, end: range.end.index }; +} + +//compare nodes using source range +function sameRange(a: SgNode, b: SgNode): boolean { + const ra = rangeOf(a); + const rb = rangeOf(b); + + return ra.start === rb.start && ra.end === rb.end; +} + +//remove async token and following whitespace +function removeToken(token: SgNode, source: string): Replacement { + const { start, end } = rangeOf(token); + const match = source.slice(end).match(/^[ \t]*/); + const trailing = match ? match[0].length : 0; + + return { start, end: end + trailing, text: '' }; +} + +//replace Promise return type with void +function replacePromiseReturnType(returnType: SgNode,): Replacement | null { + const text = returnType.text(); + + if (!/^(:\s*)?Promise\s*, reps: Replacement[],): string { + const base = rangeOf(fn).start; + let text = fn.text(); + + for (const r of [...reps].sort((a, b) => b.start - a.start)) { + text = text.slice(0, r.start - base) + r.text + text.slice(r.end - base); + } + return text; +} + +//find top level statement inside block +function topLevelIn(block: SgNode, node: SgNode): SgNode { + let current: SgNode = node; + + while (current.parent() && !sameRange(current.parent() as SgNode, block)) { + current = current.parent() as SgNode; + } + return current; +} diff --git a/recipes/stream-thenable-to-callback/tests/async-esm/expected.js b/recipes/stream-thenable-to-callback/tests/async-esm/expected.js new file mode 100644 index 00000000..73d36dbb --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-esm/expected.js @@ -0,0 +1,9 @@ +import { Writable } from "node:stream"; + +export const writable = new Writable({ + write(chunk, encoding, callback) { + database.insert(chunk.toString()) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-esm/input.js b/recipes/stream-thenable-to-callback/tests/async-esm/input.js new file mode 100644 index 00000000..201dc505 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-esm/input.js @@ -0,0 +1,8 @@ +import { Writable } from "node:stream"; + +export const writable = new Writable({ + async write(chunk, encoding, callback) { + await database.insert(chunk.toString()); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-final/expected.js b/recipes/stream-thenable-to-callback/tests/async-final/expected.js new file mode 100644 index 00000000..e1c2af66 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-final/expected.js @@ -0,0 +1,13 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + // write implementation + callback(); + }, + final(callback) { + cleanup() + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-final/input.js b/recipes/stream-thenable-to-callback/tests/async-final/input.js new file mode 100644 index 00000000..cfb414ef --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-final/input.js @@ -0,0 +1,12 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + // write implementation + callback(); + }, + async final(callback) { + await cleanup(); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-flush/expected.js b/recipes/stream-thenable-to-callback/tests/async-flush/expected.js new file mode 100644 index 00000000..7f260156 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-flush/expected.js @@ -0,0 +1,13 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk); + callback(); + }, + flush(callback) { + finalizeStream() + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-flush/input.js b/recipes/stream-thenable-to-callback/tests/async-flush/input.js new file mode 100644 index 00000000..4990a2ae --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-flush/input.js @@ -0,0 +1,12 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk); + callback(); + }, + async flush(callback) { + await finalizeStream(); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-multiple/expected.js b/recipes/stream-thenable-to-callback/tests/async-multiple/expected.js new file mode 100644 index 00000000..b1130906 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-multiple/expected.js @@ -0,0 +1,10 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + operation1(chunk) + .then(step1 => operation2(step1)) + .then(step2 => callback(null, step2)) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-multiple/input.js b/recipes/stream-thenable-to-callback/tests/async-multiple/input.js new file mode 100644 index 00000000..3d624afe --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-multiple/input.js @@ -0,0 +1,9 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + async transform(chunk, encoding, callback) { + const step1 = await operation1(chunk); + const step2 = await operation2(step1); + callback(null, step2); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/expected.js b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/expected.js new file mode 100644 index 00000000..a2e68c91 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/expected.js @@ -0,0 +1,9 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + someAsyncWork(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/input.js b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/input.js new file mode 100644 index 00000000..fac7c3e3 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-promisify-pattern/input.js @@ -0,0 +1,12 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + async write(chunk, encoding, callback) { + try { + await someAsyncWork(chunk); + callback(); + } catch (err) { + callback(err); + } + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-read/expected.js b/recipes/stream-thenable-to-callback/tests/async-read/expected.js new file mode 100644 index 00000000..9eb0692e --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-read/expected.js @@ -0,0 +1,9 @@ +const { Readable } = require("node:stream"); + +const readable = new Readable({ + read(size) { + fetchData(size) + .then(data => this.push(data)) + .catch(err => this.destroy(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-read/input.js b/recipes/stream-thenable-to-callback/tests/async-read/input.js new file mode 100644 index 00000000..2dfa319c --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-read/input.js @@ -0,0 +1,8 @@ +const { Readable } = require("node:stream"); + +const readable = new Readable({ + async read(size) { + const data = await fetchData(size); + this.push(data); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-transform/expected.js b/recipes/stream-thenable-to-callback/tests/async-transform/expected.js new file mode 100644 index 00000000..0c107b81 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-transform/expected.js @@ -0,0 +1,9 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + transform(chunk, encoding, callback) { + processChunk(chunk) + .then(result => callback(null, result)) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-transform/input.js b/recipes/stream-thenable-to-callback/tests/async-transform/input.js new file mode 100644 index 00000000..c9f5897a --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-transform/input.js @@ -0,0 +1,8 @@ +const { Transform } = require("node:stream"); + +const transform = new Transform({ + async transform(chunk, encoding, callback) { + const result = await processChunk(chunk); + callback(null, result); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-within-class/expected.js b/recipes/stream-thenable-to-callback/tests/async-within-class/expected.js new file mode 100644 index 00000000..cc7ba359 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-within-class/expected.js @@ -0,0 +1,13 @@ +const { Writable } = require("node:stream"); + +class MyWritable extends Writable { + _write(chunk, encoding, callback) { + this.processChunk(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } + + async processChunk(chunk) { + // async processing + } +} diff --git a/recipes/stream-thenable-to-callback/tests/async-within-class/input.js b/recipes/stream-thenable-to-callback/tests/async-within-class/input.js new file mode 100644 index 00000000..cc7ba359 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-within-class/input.js @@ -0,0 +1,13 @@ +const { Writable } = require("node:stream"); + +class MyWritable extends Writable { + _write(chunk, encoding, callback) { + this.processChunk(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } + + async processChunk(chunk) { + // async processing + } +} diff --git a/recipes/stream-thenable-to-callback/tests/async-write/expected.js b/recipes/stream-thenable-to-callback/tests/async-write/expected.js new file mode 100644 index 00000000..c231d215 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-write/expected.js @@ -0,0 +1,9 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + someAsyncOperation(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-write/input.js b/recipes/stream-thenable-to-callback/tests/async-write/input.js new file mode 100644 index 00000000..b550b82f --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-write/input.js @@ -0,0 +1,8 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + async write(chunk, encoding, callback) { + await someAsyncOperation(chunk); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/workflow.yaml b/recipes/stream-thenable-to-callback/workflow.yaml new file mode 100644 index 00000000..02759a70 --- /dev/null +++ b/recipes/stream-thenable-to-callback/workflow.yaml @@ -0,0 +1,25 @@ +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: "Replace thenable stream methods with callbacks (DEP0157)" + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript From 7e2085af323ba01d99987bd49b44a08899783413 Mon Sep 17 00:00:00 2001 From: HarshalBhogawade Date: Thu, 8 Oct 2026 09:44:49 +0530 Subject: [PATCH 2/3] feat(DEP0157): add codemod for stream implementation to callback base implementation --- package-lock.json | 13 +++ recipes/stream-thenable-to-callback/README.md | 108 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 recipes/stream-thenable-to-callback/README.md diff --git a/package-lock.json b/package-lock.json index 4774932b..5b40e33e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -549,6 +549,10 @@ "resolved": "recipes/slow-buffer-to-buffer-alloc-unsafe-slow", "link": true }, + "node_modules/@nodejs/stream-thenable-to-callback": { + "resolved": "recipes/stream-thenable-to-callback", + "link": true + }, "node_modules/@nodejs/timers-deprecations": { "resolved": "recipes/timers-deprecations", "link": true @@ -1254,6 +1258,15 @@ "@codemod.com/jssg-types": "^1.6.3" } }, + "recipes/stream-thenable-to-callback": { + "name": "@nodejs/stream-thenable-to-callback", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.3" + } + }, "recipes/timers-deprecations": { "name": "@nodejs/timers-deprecations", "dependencies": { diff --git a/recipes/stream-thenable-to-callback/README.md b/recipes/stream-thenable-to-callback/README.md new file mode 100644 index 00000000..c91bffd5 --- /dev/null +++ b/recipes/stream-thenable-to-callback/README.md @@ -0,0 +1,108 @@ +--- +authors: Harshall25 +--- + +# DEP0157: Thenable Support in Streams + +Node.js removed the undocumented thenable support in streams, so an `async` stream implementation method no longer has its returned promise awaited by the stream machinery: rejections are swallowed and the stream can stall. See [DEP0157](https://nodejs.org/api/deprecations.html#DEP0157). This codemod rewrites `async` implementation methods into the callback form the streams API expects, turning each sequential `await` into a `.then()` link and routing failures to a trailing `.catch()`. It covers `_write`, `_writev`, `_read`, `_transform`, `_flush` and `_final` on classes that extend a stream, and `write`, `writev`, `read`, `transform`, `flush` and `final` in the options object passed to `Writable`, `Readable`, `Transform`, `Duplex` and `PassThrough`, in both CommonJS and ESM. + +## Usage + +Run this codemod with: + +```sh +npx codemod @nodejs/stream-thenable-to-callback +``` + +## Examples + +### Example 1 + +A single `await` followed by the callback - the `async` keyword is dropped and errors are routed to `callback(err)`: + +```diff + const writable = new Writable({ +- async write(chunk, encoding, callback) { +- await someAsyncOperation(chunk); +- callback(); ++ write(chunk, encoding, callback) { ++ someAsyncOperation(chunk) ++ .then(() => callback()) ++ .catch(err => callback(err)); + } + }); +``` + +### Example 2 + +Sequential `await`s - each step becomes a `.then()` link, and the awaited binding becomes the parameter of the next one: + +```diff + const transform = new Transform({ +- async transform(chunk, encoding, callback) { +- const step1 = await operation1(chunk); +- const step2 = await operation2(step1); +- callback(null, step2); ++ transform(chunk, encoding, callback) { ++ operation1(chunk) ++ .then(step1 => operation2(step1)) ++ .then(step2 => callback(null, step2)) ++ .catch(err => callback(err)); + } + }); +``` + +### Example 3 + +`read` and `_read` take no callback, so the chain ends in `this.push()` and rejections go to `this.destroy()`: + +```diff + const readable = new Readable({ +- async read(size) { +- const data = await fetchData(size); +- this.push(data); ++ read(size) { ++ fetchData(size) ++ .then(data => this.push(data)) ++ .catch(err => this.destroy(err)); + } + }); +``` + +### Example 4 + +An existing `try`/`catch` whose handler only forwards the error is folded into the `.catch()` link: + +```diff + const writable = new Writable({ +- async write(chunk, encoding, callback) { +- try { +- await someAsyncWork(chunk); +- callback(); +- } catch (err) { +- callback(err); +- } ++ write(chunk, encoding, callback) { ++ someAsyncWork(chunk) ++ .then(() => callback()) ++ .catch(err => callback(err)); + } + }); +``` + +## Notes + +A method is only treated as a stream implementation when the stream class is resolved from a `node:stream` import in the same file: a class that extends it, or an options object passed to `new` on it. A plain object or an unrelated class that happens to have an `async write()` method is left alone, because it is not a stream. + +Indentation of the generated chain is read from the surrounding source, and the file's existing line endings are preserved. + +### Limitations + +The rewrite is skipped, and the original `async` method left untouched, whenever the chain could not be reproduced faithfully: + +- **The first awaited expression is optionally chained.** `await this.connection?.close()` short-circuits to `undefined` when the receiver is nullish, so `.then()` would throw instead of calling back. +- **A binding is used more than one step later.** In a `.then()` chain each step only sees the previous step's value, so a variable read two statements after it was declared cannot be expressed without restructuring. +- **The `catch` block does more than forward the error.** A handler that also logs, retries or branches is not collapsed into `.catch(err => callback(err))`, and a `finally` block is never rewritten. +- **An `await` is not a whole statement.** Only `await expr;` and `const name = await expr;` are chained; something like `log(await save(chunk));` is not. +- **Async generators.** `async *_read()` is a different protocol and is never rewritten. +- **A step rebinds the callback parameter**, which would change what `.catch()` ends up calling. From 4f9ea0ebf210bb143887d266e3e958e5ffc01d24 Mon Sep 17 00:00:00 2001 From: HarshalBhogawade Date: Sat, 10 Oct 2026 21:08:41 +0530 Subject: [PATCH 3/3] feat(DEP0157): refactor kind, use util EOL, regex fix and tests for paired streams --- .../src/workflow.ts | 109 ++++++------------ .../async-pair-double-quoted/expected.js | 10 ++ .../tests/async-pair-double-quoted/input.js | 9 ++ .../tests/async-pair-no-qoutes/expected.js | 10 ++ .../tests/async-pair-no-qoutes/input.js | 9 ++ .../async-pair-single-quoted/expected.js | 10 ++ .../tests/async-pair-single-quoted/input.js | 9 ++ 7 files changed, 92 insertions(+), 74 deletions(-) create mode 100644 recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/input.js diff --git a/recipes/stream-thenable-to-callback/src/workflow.ts b/recipes/stream-thenable-to-callback/src/workflow.ts index 619b6b1c..12e8294b 100644 --- a/recipes/stream-thenable-to-callback/src/workflow.ts +++ b/recipes/stream-thenable-to-callback/src/workflow.ts @@ -3,6 +3,7 @@ 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'; const STREAM_CLASSES = [ 'Writable', 'Readable', 'Transform', 'Duplex', 'PassThrough', @@ -60,7 +61,6 @@ type Replacement = { start: number; end: number; text: string }; type FileContext = { source: string; - eol: string; indentUnit: string; }; @@ -89,7 +89,6 @@ export default function transform(root: SgRoot): string | null { const source = rootNode.text(); const context: FileContext = { source, - eol: source.includes('\r\n') ? '\r\n' : '\n', indentUnit: detectIndentUnit(source), }; @@ -128,7 +127,7 @@ function getStreamClassNames(root: SgRoot): Set { } //find methods in the stream classes -function findStreamFunctions(rootNode: SgNode, streamNames: Set,): StreamFunction[] { +function findStreamFunctions(rootNode: SgNode, streamNames: Set): StreamFunction[] { const found: StreamFunction[] = []; const methods = rootNode.findAll({ rule: { kind: 'method_definition' } }); @@ -154,8 +153,7 @@ function findStreamFunctions(rootNode: SgNode, streamNames: Set,): S const value = pair.field('value'); if (!key || !value) continue; - const name = key.text().replace(/^(['"])(.*)\1$/, '$2'); - + const name = key.find({ rule: { kind: 'string_fragment' } })?.text() ?? key.text(); if ( OPTION_METHODS.has(name) && FUNCTION_VALUE_KINDS.has(value.kind()) && @@ -171,7 +169,7 @@ function findStreamFunctions(rootNode: SgNode, streamNames: Set,): S //check if a class method belongs to a class that extends one of stream classes function extendsStream(member: SgNode, streamNames: Set): boolean { const classBody = member.parent(); - if (classBody?.kind() !== 'class_body') return false; + if (!classBody?.is('class_body')) return false; const classNode = classBody.parent(); if (!classNode) return false; @@ -179,22 +177,22 @@ function extendsStream(member: SgNode, streamNames: Set): boolean { const heritage = classNode.find({ rule: { kind: 'class_heritage' } }); if (!heritage) return false; - const parts = heritage.children().filter((c) => c.kind() !== 'extends'); + const parts = heritage.children().filter((c) => !c.is('extends')); const superclass = parts[parts.length - 1]; return !!superclass && streamNames.has(withoutSpaces(superclass.text())); } // Checks if an object method property belongs to options passed -function isStreamOptionsMember(member: SgNode, streamNames: Set,): boolean { +function isStreamOptionsMember(member: SgNode, streamNames: Set): boolean { const object = member.parent(); - if (object?.kind() !== 'object') return false; + if (!object?.is('object')) return false; const args = object.parent(); - if (args?.kind() !== 'arguments') return false; + if (!args?.is('arguments')) return false; const newExpression = args.parent(); - if (newExpression?.kind() !== 'new_expression') return false; + if (!newExpression?.is('new_expression')) return false; const ctor = newExpression.field('constructor'); if (!ctor) return false; @@ -203,16 +201,16 @@ function isStreamOptionsMember(member: SgNode, streamNames: Set,): b } //main transromation is done here. -function transformStream({ fn, name }: StreamFunction, context: FileContext,): Edit | null { +function transformStream({ fn, name }: StreamFunction, context: FileContext): Edit | null { const fnChildren = fn.children(); - const asyncToken = fnChildren.find((child) => child.kind() === 'async'); + const asyncToken = fnChildren.find((child) => child.is('async')); if (!asyncToken) return null; - if (fnChildren.some((child) => child.kind() === '*')) return null; + if (fnChildren.some((child) => child.is('*'))) return null; const body = fn.field('body'); - if (body?.kind() !== 'statement_block') return null; + if (!body?.is('statement_block')) return null; const awaits = body .findAll({ rule: { kind: 'await_expression' } }) @@ -274,30 +272,9 @@ function transformStream({ fn, name }: StreamFunction, context: FileContext,): E // Inlined indentation logic using the provided getLineIndent util const bodyRange = rangeOf(body); - const statementStart = rangeOf(topLevelIn(body, statements[0])).start; const closingIndent = getLineIndent(context.source, bodyRange.end - 1); - - const sliceStr = context.source.slice( - Math.min(bodyRange.start, statementStart), - Math.max(bodyRange.start, statementStart) - ); - - const isOnSameLine = !sliceStr.includes('\n'); - - let bodyIndent: string; - let unit: string; - - if (!isOnSameLine) { - bodyIndent = getLineIndent(context.source, statementStart); - unit = (bodyIndent.length > closingIndent.length && bodyIndent.startsWith(closingIndent)) - ? bodyIndent.slice(closingIndent.length) - : context.indentUnit; - } else { - unit = context.indentUnit; - bodyIndent = closingIndent + unit; - } - - const chainIndent = bodyIndent + unit; + const bodyIndent = closingIndent + context.indentUnit; + const chainIndent = bodyIndent + context.indentUnit; const lines: string[] = comments.map( (c) => `${bodyIndent}${c.text().trim()}`, @@ -320,11 +297,10 @@ function transformStream({ fn, name }: StreamFunction, context: FileContext,): E lines.push(`${chainIndent}.catch(${errorName} => ${errorHandler});`); - const { eol } = context; const replacements: Replacement[] = [ { ...bodyRange, - text: `{${eol}${lines.join(eol)}${eol}${closingIndent}}`, + text: `{${EOL}${lines.join(EOL)}${EOL}${closingIndent}}`, }, removeToken(asyncToken, context.source), ]; @@ -343,7 +319,7 @@ function extractBody(body: SgNode): ExtractedBody | null { const statements = getStatements(body); const comments = getComments(body); - if (statements.length === 1 && statements[0].kind() === 'try_statement') { + if (statements.length === 1 && statements[0].is('try_statement')) { const tryStmt = statements[0]; if (tryStmt.field('finalizer')) { @@ -357,7 +333,7 @@ function extractBody(body: SgNode): ExtractedBody | null { const parameter = handler.field('parameter'); - if (parameter?.kind() !== 'identifier') return null; + if (!parameter?.is('identifier')) return null; const handlerBody = handler.field('body'); if (!handlerBody) return null; @@ -383,24 +359,23 @@ function extractBody(body: SgNode): ExtractedBody | null { function getStep(statement: SgNode): Step | null { let awaitedNode: SgNode | null = null; let boundName: SgNode | null = null; - const kind = statement.kind(); - if (kind === 'expression_statement') { + if (statement.is('expression_statement')) { awaitedNode = statement.child(0) ?? null; - } else if (kind === 'lexical_declaration' || kind === 'variable_declaration') { + } else if (statement.is('lexical_declaration') || statement.is('variable_declaration')) { const decls = statement .children() - .filter((child) => child.kind() === 'variable_declarator'); + .filter((child) => child.is('variable_declarator')); if (decls.length !== 1) return null; awaitedNode = decls[0].field('value') ?? null; boundName = decls[0].field('name') ?? null; } - if (awaitedNode?.kind() !== 'await_expression') return null; + if (!awaitedNode?.is('await_expression')) return null; const argument = awaitedNode .children() - .find((c) => c.kind() !== 'await' && c.kind() !== 'comment'); + .find((c) => !c.is('await') && !c.is('comment')); if (!argument) return null; return { statement, argument, name: boundName }; @@ -418,13 +393,11 @@ function getCallbackParamName(fn: SgNode): string | null { const last = list[list.length - 1]; if (!last) return null; - const kind = last.kind(); - - if (kind === 'identifier') return last.text(); + if (last.is('identifier')) return last.text(); - if (kind === 'required_parameter' || kind === 'optional_parameter') { + if (last.is('required_parameter') || last.is('optional_parameter')) { const pattern = last.child(0); - return pattern?.kind() === 'identifier' ? pattern.text() : null; + return pattern?.is('identifier') ? pattern.text() : null; } return null; } @@ -435,9 +408,7 @@ const withoutSpaces = (text: string) => text.replace(/\s+/g, ''); //get call expression from statement function getStatementCall(statement: SgNode): SgNode | null { - const kind = statement.kind(); - - if (kind !== 'expression_statement' && kind !== 'return_statement') { + if (!statement.is('expression_statement') && !statement.is('return_statement')) { return null; } @@ -445,7 +416,7 @@ function getStatementCall(statement: SgNode): SgNode | null { .children() .find((c) => !['return', ';', 'comment'].includes(c.kind())); - return call?.kind() === 'call_expression' ? call : null; + return call?.is('call_expression') ? call : null; } //check if call uses expected callee @@ -463,12 +434,12 @@ function getCallWithCallee( const getCallbackCall = (statement: SgNode, cbName: string) => getCallWithCallee( statement, - (callee) => callee.kind() === 'identifier' && callee.text() === cbName, + (callee) => callee.is('identifier') && callee.text() === cbName, ); //find this.push call from statement const getPushCall = (statement: SgNode) => - statement.kind() === 'expression_statement' + statement.is('expression_statement') ? getCallWithCallee( statement, (callee) => withoutSpaces(callee.text()) === 'this.push', @@ -477,7 +448,7 @@ const getPushCall = (statement: SgNode) => //check if statement is expected call function isCallStatement(statement: SgNode, expected: string): boolean { - if (statement.kind() !== 'expression_statement') return false; + if (!statement.is('expression_statement')) return false; const call = getStatementCall(statement); @@ -507,7 +478,7 @@ function hasSafeScoping(steps: Step[], finalStatement: SgNode): boolean { //get variables bound by a node function getBoundNames(name: SgNode): string[] { - if (name.kind() === 'identifier') return [name.text()]; + if (name.is('identifier')) return [name.text()]; return name .findAll({ @@ -543,7 +514,7 @@ function getArrowParameter(step: Step, consumer: SgNode): string { return '()'; } - if (step.name.kind() === 'identifier') { + if (step.name.is('identifier')) { return step.name.text(); } return `(${step.name.text()})`; @@ -578,12 +549,12 @@ function belongsTo(node: SgNode, fn: SgNode): boolean { function getStatements(block: SgNode): SgNode[] { return block .children() - .filter((c) => !['{', '}', 'comment'].includes(c.kind())); + .filter((c) => !c.is('{') && !c.is('}') && !c.is('comment')); } //get comments directly inside block function getComments(block: SgNode): SgNode[] { - return block.children().filter((c) => c.kind() === 'comment'); + return block.children().filter((c) => c.is('comment')); } //get source range of node @@ -632,13 +603,3 @@ function applyReplacements(fn: SgNode, reps: Replacement[],): string { } return text; } - -//find top level statement inside block -function topLevelIn(block: SgNode, node: SgNode): SgNode { - let current: SgNode = node; - - while (current.parent() && !sameRange(current.parent() as SgNode, block)) { - current = current.parent() as SgNode; - } - return current; -} diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/expected.js b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/expected.js new file mode 100644 index 00000000..2bf669f1 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/expected.js @@ -0,0 +1,10 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + "write": function (chunk, encoding, callback) + { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + }, +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/input.js b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/input.js new file mode 100644 index 00000000..9173d074 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-double-quoted/input.js @@ -0,0 +1,9 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + "write": async function (chunk, encoding, callback) + { + await save(chunk); + callback(); + }, +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/expected.js b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/expected.js new file mode 100644 index 00000000..5fa6587c --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/expected.js @@ -0,0 +1,10 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + write: function (chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + }, + objectMode: true +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/input.js b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/input.js new file mode 100644 index 00000000..a92dd4c8 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-no-qoutes/input.js @@ -0,0 +1,9 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + write: async function (chunk, encoding, callback) { + await save(chunk); + callback(); + }, + objectMode: true +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/expected.js b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/expected.js new file mode 100644 index 00000000..afc78120 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/expected.js @@ -0,0 +1,10 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + 'write': function (chunk, encoding, callback) + { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + }, +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/input.js b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/input.js new file mode 100644 index 00000000..537a7af4 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-pair-single-quoted/input.js @@ -0,0 +1,9 @@ +import { Writable } from 'node:stream'; + +const stream = new Writable({ + 'write': async function (chunk, encoding, callback) + { + await save(chunk); + callback(); + }, +});