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/7] 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/7] 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/7] 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(); + }, +}); From b26ca7872aea79c3984bbbab9b88fd1d57c1fb0b Mon Sep 17 00:00:00 2001 From: HarshalBhogawade Date: Mon, 12 Oct 2026 23:06:14 +0530 Subject: [PATCH 4/7] fix(DEP0157): arrow function case in paired streams --- recipes/stream-thenable-to-callback/src/workflow.ts | 4 ++-- .../tests/async-pair-double-quoted/expected.js | 8 ++++++++ .../tests/async-pair-double-quoted/input.js | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/recipes/stream-thenable-to-callback/src/workflow.ts b/recipes/stream-thenable-to-callback/src/workflow.ts index 12e8294b..0f498f09 100644 --- a/recipes/stream-thenable-to-callback/src/workflow.ts +++ b/recipes/stream-thenable-to-callback/src/workflow.ts @@ -582,7 +582,7 @@ function removeToken(token: SgNode, source: string): Replacement { } //replace Promise return type with void -function replacePromiseReturnType(returnType: SgNode,): Replacement | null { +function replacePromiseReturnType(returnType: SgNode): Replacement | null { const text = returnType.text(); if (!/^(:\s*)?Promise\s*,): Replacement | null { } //apply replacements from the end so ranges stay valid -function applyReplacements(fn: SgNode, reps: Replacement[],): string { +function applyReplacements(fn: SgNode, reps: Replacement[]): string { const base = rangeOf(fn).start; let text = fn.text(); 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 index 2bf669f1..21abd4aa 100644 --- 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 @@ -8,3 +8,11 @@ const stream = new Writable({ .catch(err => callback(err)); }, }); + +const stream2 = new Writable({ + "write": (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 index 9173d074..fc87ff5f 100644 --- 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 @@ -7,3 +7,10 @@ const stream = new Writable({ callback(); }, }); + +const stream2 = new Writable({ + "write": async (chunk, encoding, callback) => { + await save(chunk); + callback(); + }, +}); From 8455887f1d818deb24f735803a7a53d86d2fce28 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 12 Oct 2026 20:01:27 -0500 Subject: [PATCH 5/7] fix(stream-thenable-to-callback): avoid shadowing callback in catch --- recipes/stream-thenable-to-callback/src/workflow.ts | 2 +- .../tests/async-callback-named-err/expected.js | 9 +++++++++ .../tests/async-callback-named-err/input.js | 8 ++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 recipes/stream-thenable-to-callback/tests/async-callback-named-err/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-callback-named-err/input.js diff --git a/recipes/stream-thenable-to-callback/src/workflow.ts b/recipes/stream-thenable-to-callback/src/workflow.ts index 0f498f09..2ec71b23 100644 --- a/recipes/stream-thenable-to-callback/src/workflow.ts +++ b/recipes/stream-thenable-to-callback/src/workflow.ts @@ -259,7 +259,7 @@ function transformStream({ fn, name }: StreamFunction, context: FileContext): Ed if (!finalCall) return null; - const errorName = catchParam ?? 'err'; + const errorName = catchParam ?? (callbackName === 'err' ? 'error' : 'err'); const errorHandler = isRead ? `this.destroy(${errorName})` : `${callbackName}(${errorName})`; diff --git a/recipes/stream-thenable-to-callback/tests/async-callback-named-err/expected.js b/recipes/stream-thenable-to-callback/tests/async-callback-named-err/expected.js new file mode 100644 index 00000000..0f76b06a --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-callback-named-err/expected.js @@ -0,0 +1,9 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, err) { + someAsyncOperation(chunk) + .then(() => err()) + .catch(error => err(error)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-callback-named-err/input.js b/recipes/stream-thenable-to-callback/tests/async-callback-named-err/input.js new file mode 100644 index 00000000..00eca269 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-callback-named-err/input.js @@ -0,0 +1,8 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + async write(chunk, encoding, err) { + await someAsyncOperation(chunk); + err(); + } +}); From 2d490583ad7462b3b30293512dba0cfdbc63766e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 12 Oct 2026 20:11:48 -0500 Subject: [PATCH 6/7] fix(stream-thenable-to-callback): preserve destructuring side effects --- recipes/stream-thenable-to-callback/src/workflow.ts | 7 +++---- .../tests/async-unused-array-destructuring/expected.js | 9 +++++++++ .../tests/async-unused-array-destructuring/input.js | 8 ++++++++ .../tests/async-unused-object-destructuring/expected.js | 9 +++++++++ .../tests/async-unused-object-destructuring/input.js | 8 ++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/input.js diff --git a/recipes/stream-thenable-to-callback/src/workflow.ts b/recipes/stream-thenable-to-callback/src/workflow.ts index 2ec71b23..45299134 100644 --- a/recipes/stream-thenable-to-callback/src/workflow.ts +++ b/recipes/stream-thenable-to-callback/src/workflow.ts @@ -510,13 +510,12 @@ function referencesAny(node: SgNode, names: string[]): boolean { function getArrowParameter(step: Step, consumer: SgNode): string { if (!step.name) return '()'; - if (!referencesAny(consumer, getBoundNames(step.name))) { - return '()'; - } - if (step.name.is('identifier')) { + if (!referencesAny(consumer, getBoundNames(step.name))) return '()'; + return step.name.text(); } + // Destructuring can run getters, iterators and defaults even for unused bindings. return `(${step.name.text()})`; } diff --git a/recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/expected.js b/recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/expected.js new file mode 100644 index 00000000..78c8fa5d --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/expected.js @@ -0,0 +1,9 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(([value = initialize()]) => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/input.js b/recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/input.js new file mode 100644 index 00000000..a83fd830 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-unused-array-destructuring/input.js @@ -0,0 +1,8 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + async write(chunk, encoding, callback) { + const [value = initialize()] = await save(chunk); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/expected.js b/recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/expected.js new file mode 100644 index 00000000..324bb113 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/expected.js @@ -0,0 +1,9 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(({ value = initialize() }) => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/input.js b/recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/input.js new file mode 100644 index 00000000..4c9803f3 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/async-unused-object-destructuring/input.js @@ -0,0 +1,8 @@ +const { Writable } = require("node:stream"); + +const writable = new Writable({ + async write(chunk, encoding, callback) { + const { value = initialize() } = await save(chunk); + callback(); + } +}); From dbf0ea5e391938321fdaf1e29f54e06bcfad0933 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Mon, 12 Oct 2026 20:19:02 -0500 Subject: [PATCH 7/7] fix(stream-thenable-to-callback): respect shadowed stream bindings --- .../src/workflow.ts | 63 ++++++++++++++----- .../tests/scoped-imports/expected.js | 31 +++++++++ .../tests/scoped-imports/input.js | 29 +++++++++ .../tests/shadowed-alias/expected.js | 16 +++++ .../tests/shadowed-alias/input.js | 15 +++++ .../tests/shadowed-dynamic-import/expected.js | 18 ++++++ .../tests/shadowed-dynamic-import/input.js | 17 +++++ .../tests/shadowed-local/expected.js | 19 ++++++ .../tests/shadowed-local/input.js | 18 ++++++ .../tests/shadowed-namespace/expected.js | 18 ++++++ .../tests/shadowed-namespace/input.js | 17 +++++ .../tests/shadowed-parameter/expected.js | 18 ++++++ .../tests/shadowed-parameter/input.js | 17 +++++ .../stream-thenable-to-callback/workflow.yaml | 1 + 14 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 recipes/stream-thenable-to-callback/tests/scoped-imports/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/scoped-imports/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-alias/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-alias/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-local/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-local/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-namespace/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-namespace/input.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-parameter/expected.js create mode 100644 recipes/stream-thenable-to-callback/tests/shadowed-parameter/input.js diff --git a/recipes/stream-thenable-to-callback/src/workflow.ts b/recipes/stream-thenable-to-callback/src/workflow.ts index 45299134..0c4ab0d7 100644 --- a/recipes/stream-thenable-to-callback/src/workflow.ts +++ b/recipes/stream-thenable-to-callback/src/workflow.ts @@ -43,6 +43,7 @@ const NEVER_THENABLE_KINDS = new Set([ const ARROW_BODY_NEEDS_PARENS = new Set(['object', 'sequence_expression']); type StreamFunction = { fn: SgNode; name: string; }; +type StreamBindings = Map[]>; type Step = { statement: SgNode; @@ -83,8 +84,8 @@ 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 streamBindings = getStreamClassBindings(root); + if (!streamBindings.size) return null; const source = rootNode.text(); const context: FileContext = { @@ -95,7 +96,7 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; const edited: Array<[number, number]> = []; - for (const target of findStreamFunctions(rootNode, streamNames)) { + for (const target of findStreamFunctions(rootNode, streamBindings)) { const { start, end } = rangeOf(target.fn); if (edited.some(([s, e]) => start < e && s < end)) continue; @@ -113,21 +114,53 @@ export default function transform(root: SgRoot): string | null { } //find stream classes only -function getStreamClassNames(root: SgRoot): Set { - const names = new Set(); +function getStreamClassBindings(root: SgRoot): StreamBindings { + const bindings: StreamBindings = new Map(); for (const statement of getModuleDependencies(root, 'stream')) { + let declaration = statement; + if (statement.is('expression_statement')) { + // Dynamic import bindings belong to the callback parameters, not its body. + const callback = statement.find({ + rule: { any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }] }, + }); + declaration = callback?.field('parameter') ?? callback?.field('parameters'); + if (!declaration) continue; + } + for (const className of STREAM_CLASSES) { const localPath = resolveBindingPath(statement, `$.${className}`); - if (localPath) names.add(localPath); + if (!localPath) continue; + + const declarations = bindings.get(localPath) ?? []; + declarations.push(declaration); + bindings.set(localPath, declarations); } } - return names; + return bindings; +} + +function isStreamConstructor(node: SgNode, bindings: StreamBindings): boolean { + const declarations = bindings.get(withoutSpaces(node.text())); + if (!declarations) return false; + + let binding = node; + while (binding.is('member_expression')) binding = binding.field('object'); + if (!binding.is('identifier')) return false; + + const definition = binding.definition({ resolveExternal: false }); + if (!definition || definition.root.filename() !== node.getRoot().filename()) return false; + + const definitionRange = rangeOf(definition.node); + return declarations.some((declaration) => { + const { start, end } = rangeOf(declaration); + return start <= definitionRange.start && definitionRange.end <= end; + }); } //find methods in the stream classes -function findStreamFunctions(rootNode: SgNode, streamNames: Set): StreamFunction[] { +function findStreamFunctions(rootNode: SgNode, streamBindings: StreamBindings): StreamFunction[] { const found: StreamFunction[] = []; const methods = rootNode.findAll({ rule: { kind: 'method_definition' } }); @@ -135,11 +168,11 @@ function findStreamFunctions(rootNode: SgNode, streamNames: Set): St const name = method.field('name')?.text(); if (!name) continue; - if (CLASS_METHODS.has(name) && extendsStream(method, streamNames)) { + if (CLASS_METHODS.has(name) && extendsStream(method, streamBindings)) { found.push({ fn: method, name }); } else if ( OPTION_METHODS.has(name) && - isStreamOptionsMember(method, streamNames) + isStreamOptionsMember(method, streamBindings) ) { found.push({ fn: method, name }); } @@ -157,7 +190,7 @@ function findStreamFunctions(rootNode: SgNode, streamNames: Set): St if ( OPTION_METHODS.has(name) && FUNCTION_VALUE_KINDS.has(value.kind()) && - isStreamOptionsMember(pair, streamNames) + isStreamOptionsMember(pair, streamBindings) ) { found.push({ fn: value, name }); } @@ -167,7 +200,7 @@ function findStreamFunctions(rootNode: SgNode, streamNames: Set): St } //check if a class method belongs to a class that extends one of stream classes -function extendsStream(member: SgNode, streamNames: Set): boolean { +function extendsStream(member: SgNode, streamBindings: StreamBindings): boolean { const classBody = member.parent(); if (!classBody?.is('class_body')) return false; @@ -180,11 +213,11 @@ function extendsStream(member: SgNode, streamNames: Set): boolean { const parts = heritage.children().filter((c) => !c.is('extends')); const superclass = parts[parts.length - 1]; - return !!superclass && streamNames.has(withoutSpaces(superclass.text())); + return !!superclass && isStreamConstructor(superclass, streamBindings); } // Checks if an object method property belongs to options passed -function isStreamOptionsMember(member: SgNode, streamNames: Set): boolean { +function isStreamOptionsMember(member: SgNode, streamBindings: StreamBindings): boolean { const object = member.parent(); if (!object?.is('object')) return false; @@ -197,7 +230,7 @@ function isStreamOptionsMember(member: SgNode, streamNames: Set): bo const ctor = newExpression.field('constructor'); if (!ctor) return false; - return streamNames.has(withoutSpaces(ctor.text())); + return isStreamConstructor(ctor, streamBindings); } //main transromation is done here. diff --git a/recipes/stream-thenable-to-callback/tests/scoped-imports/expected.js b/recipes/stream-thenable-to-callback/tests/scoped-imports/expected.js new file mode 100644 index 00000000..d51bda70 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/scoped-imports/expected.js @@ -0,0 +1,31 @@ +function first() { + const { Writable } = require("node:stream"); + return new Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } + }); +} + +function second() { + const { Writable } = require("stream"); + return new Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } + }); +} + +function custom() { + const { Writable } = require("custom-stream"); + return new Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} diff --git a/recipes/stream-thenable-to-callback/tests/scoped-imports/input.js b/recipes/stream-thenable-to-callback/tests/scoped-imports/input.js new file mode 100644 index 00000000..d5a2c820 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/scoped-imports/input.js @@ -0,0 +1,29 @@ +function first() { + const { Writable } = require("node:stream"); + return new Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} + +function second() { + const { Writable } = require("stream"); + return new Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} + +function custom() { + const { Writable } = require("custom-stream"); + return new Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-alias/expected.js b/recipes/stream-thenable-to-callback/tests/shadowed-alias/expected.js new file mode 100644 index 00000000..ab255a6a --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-alias/expected.js @@ -0,0 +1,16 @@ +import { Writable as Sink } from "node:stream"; + +const build = ({ Sink }) => new Sink({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } +}); + +const writable = new Sink({ + write(chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-alias/input.js b/recipes/stream-thenable-to-callback/tests/shadowed-alias/input.js new file mode 100644 index 00000000..6292a7da --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-alias/input.js @@ -0,0 +1,15 @@ +import { Writable as Sink } from "node:stream"; + +const build = ({ Sink }) => new Sink({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } +}); + +const writable = new Sink({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/expected.js b/recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/expected.js new file mode 100644 index 00000000..2743ccc6 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/expected.js @@ -0,0 +1,18 @@ +import("node:stream").then(stream => { + function build(stream) { + return new stream.Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); + } + + const writable = new stream.Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } + }); +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/input.js b/recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/input.js new file mode 100644 index 00000000..d8c3a5f7 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-dynamic-import/input.js @@ -0,0 +1,17 @@ +import("node:stream").then(stream => { + function build(stream) { + return new stream.Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); + } + + const writable = new stream.Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-local/expected.js b/recipes/stream-thenable-to-callback/tests/shadowed-local/expected.js new file mode 100644 index 00000000..e8e6475e --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-local/expected.js @@ -0,0 +1,19 @@ +const stream = require("node:stream"); + +{ + const stream = customStream; + const custom = new stream.Writable({ + write: async function (chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} + +const writable = new stream.Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-local/input.js b/recipes/stream-thenable-to-callback/tests/shadowed-local/input.js new file mode 100644 index 00000000..4b38a58f --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-local/input.js @@ -0,0 +1,18 @@ +const stream = require("node:stream"); + +{ + const stream = customStream; + const custom = new stream.Writable({ + write: async function (chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} + +const writable = new stream.Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-namespace/expected.js b/recipes/stream-thenable-to-callback/tests/shadowed-namespace/expected.js new file mode 100644 index 00000000..7cee5237 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-namespace/expected.js @@ -0,0 +1,18 @@ +import * as stream from "node:stream"; + +function build(stream) { + return new stream.Writable({ + write: async (chunk, encoding, callback) => { + await save(chunk); + callback(); + } + }); +} + +const writable = new stream.Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-namespace/input.js b/recipes/stream-thenable-to-callback/tests/shadowed-namespace/input.js new file mode 100644 index 00000000..0bc94f92 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-namespace/input.js @@ -0,0 +1,17 @@ +import * as stream from "node:stream"; + +function build(stream) { + return new stream.Writable({ + write: async (chunk, encoding, callback) => { + await save(chunk); + callback(); + } + }); +} + +const writable = new stream.Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-parameter/expected.js b/recipes/stream-thenable-to-callback/tests/shadowed-parameter/expected.js new file mode 100644 index 00000000..dbdceb80 --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-parameter/expected.js @@ -0,0 +1,18 @@ +const { Writable } = require("node:stream"); + +function build(Writable) { + return new Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} + +const writable = new Writable({ + write(chunk, encoding, callback) { + save(chunk) + .then(() => callback()) + .catch(err => callback(err)); + } +}); diff --git a/recipes/stream-thenable-to-callback/tests/shadowed-parameter/input.js b/recipes/stream-thenable-to-callback/tests/shadowed-parameter/input.js new file mode 100644 index 00000000..ab47cd3a --- /dev/null +++ b/recipes/stream-thenable-to-callback/tests/shadowed-parameter/input.js @@ -0,0 +1,17 @@ +const { Writable } = require("node:stream"); + +function build(Writable) { + return new Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } + }); +} + +const writable = new Writable({ + async write(chunk, encoding, callback) { + await save(chunk); + callback(); + } +}); diff --git a/recipes/stream-thenable-to-callback/workflow.yaml b/recipes/stream-thenable-to-callback/workflow.yaml index 02759a70..a5a90fac 100644 --- a/recipes/stream-thenable-to-callback/workflow.yaml +++ b/recipes/stream-thenable-to-callback/workflow.yaml @@ -23,3 +23,4 @@ nodes: exclude: - "**/node_modules/**" language: typescript + semantic_analysis: file