From c8b9b348a0928f797eabd264b0a433458c0b9656 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:56:15 +0200 Subject: [PATCH 1/7] feat(`http2-priority-signaling`): introduce --- package-lock.json | 15 + recipes/http2-priority-signaling/README.md | 53 +++ recipes/http2-priority-signaling/codemod.yaml | 23 ++ recipes/http2-priority-signaling/package.json | 24 ++ .../http2-priority-signaling/src/workflow.ts | 361 ++++++++++++++++++ .../tests/expected/case1-connect-priority.js | 2 + .../tests/expected/case2-request-priority.js | 4 + .../expected/case3-stream-priority-method.js | 1 + .../expected/case4-settings-priority-esm.mjs | 3 + .../tests/input/case1-connect-priority.js | 8 + .../tests/input/case2-request-priority.js | 5 + .../input/case3-stream-priority-method.js | 6 + .../input/case4-settings-priority-esm.mjs | 3 + .../http2-priority-signaling/workflow.yaml | 25 ++ 14 files changed, 533 insertions(+) create mode 100644 recipes/http2-priority-signaling/README.md create mode 100644 recipes/http2-priority-signaling/codemod.yaml create mode 100644 recipes/http2-priority-signaling/package.json create mode 100644 recipes/http2-priority-signaling/src/workflow.ts create mode 100644 recipes/http2-priority-signaling/tests/expected/case1-connect-priority.js create mode 100644 recipes/http2-priority-signaling/tests/expected/case2-request-priority.js create mode 100644 recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js create mode 100644 recipes/http2-priority-signaling/tests/expected/case4-settings-priority-esm.mjs create mode 100644 recipes/http2-priority-signaling/tests/input/case1-connect-priority.js create mode 100644 recipes/http2-priority-signaling/tests/input/case2-request-priority.js create mode 100644 recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js create mode 100644 recipes/http2-priority-signaling/tests/input/case4-settings-priority-esm.mjs create mode 100644 recipes/http2-priority-signaling/workflow.yaml diff --git a/package-lock.json b/package-lock.json index 802c1bd9..20131783 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1485,6 +1485,10 @@ "resolved": "recipes/http-classes-with-new", "link": true }, + "node_modules/@nodejs/http2-priority-signaling": { + "resolved": "recipes/http2-priority-signaling", + "link": true + }, "node_modules/@nodejs/import-assertions-to-attributes": { "resolved": "recipes/import-assertions-to-attributes", "link": true @@ -4323,6 +4327,17 @@ "@codemod.com/jssg-types": "^1.0.9" } }, + "recipes/http2-priority-signaling": { + "name": "@nodejs/http2-priority-signaling", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + } + }, "recipes/import-assertions-to-attributes": { "name": "@nodejs/import-assertions-to-attributes", "version": "1.0.0", diff --git a/recipes/http2-priority-signaling/README.md b/recipes/http2-priority-signaling/README.md new file mode 100644 index 00000000..9e0bc5a7 --- /dev/null +++ b/recipes/http2-priority-signaling/README.md @@ -0,0 +1,53 @@ +# HTTP/2 Priority Signaling Removal - DEP0194 + +This recipe removes HTTP/2 priority-related options and methods since priority signaling has been deprecated. + +See [DEP0194](https://nodejs.org/api/deprecations.html#DEP0194). + + +## What this codemod does + +- Removes the `priority` property from `http2.connect()` call options +- Removes the `priority` property from `session.request()` call options +- Removes entire `stream.priority()` method call statements +- Removes the `priority` property from `client.settings()` call options +- Handles both CommonJS (`require()`) and ESM (`import`) imports + +## Examples + +**Before:** + +```js +// CommonJS usage +const http2 = require("node:http2"); +const session = http2.connect("https://example.com", { + priority: { weight: 16, parent: 0, exclusive: false } +}); +const stream = session.request({ + ":path": "/api/data", + priority: { weight: 32 } +}); +stream.priority({ exclusive: true, parent: 0, weight: 128 }); + +// ESM usage +import http2 from "node:http2"; +const client = http2.connect("https://example.com"); +client.settings({ enablePush: false, priority: true }); +``` + +**After:** + +```js +// CommonJS usage +const http2 = require("node:http2"); +const session = http2.connect("https://example.com"); +const stream = session.request({ + ":path": "/api/data" +}); +// stream.priority() removed + +// ESM usage +import http2 from "node:http2"; +const client = http2.connect("https://example.com"); +client.settings({ enablePush: false }); +``` diff --git a/recipes/http2-priority-signaling/codemod.yaml b/recipes/http2-priority-signaling/codemod.yaml new file mode 100644 index 00000000..dd78010e --- /dev/null +++ b/recipes/http2-priority-signaling/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/http2-priority-signaling" +version: 1.0.0 +description: Handle DEP0194 via removing HTTP/2 priority-related options and methods. +author: Augustin Mauroy +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - http2 + - deprecation + +registry: + access: public + visibility: public diff --git a/recipes/http2-priority-signaling/package.json b/recipes/http2-priority-signaling/package.json new file mode 100644 index 00000000..477c0091 --- /dev/null +++ b/recipes/http2-priority-signaling/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/http2-priority-signaling", + "version": "1.0.0", + "description": "Handle DEP0194 via removing HTTP/2 priority-related options and methods", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/http2-priority-signaling", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Augustin Mauroy", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/http2-priority-signaling/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts new file mode 100644 index 00000000..3afc5470 --- /dev/null +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -0,0 +1,361 @@ +import type { + Edit, + Range, + SgNode, + SgRoot, +} from "@codemod.com/jssg-types/main"; +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; + +/* + * Transforms HTTP/2 priority-related options and methods. + * + * Steps: + * + * 1. Find all http2 imports and require calls + * 2. Find and remove priority property from connect() options + * 3. Find and remove priority property from request() options + * 4. Find and remove complete stream.priority() calls + * 5. Find and remove priority property from settings() options + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + // Get all http2 imports/requires + const http2Imports = getNodeImportStatements(root, "http2"); + const http2Requires = getNodeRequireCalls(root, "http2"); + const http2Statements = [...http2Imports, ...http2Requires]; + + if (!http2Statements.length) return null; + + // Case 1: Remove priority object from http2.connect() options + edits.push(...removeConnectPriority(rootNode)); + + // Case 2: Remove priority object from session.request() options + edits.push(...removeRequestPriority(rootNode)); + + // Case 3: Remove entire stream.priority() method calls + const result3 = removePriorityMethodCalls(rootNode); + edits.push(...result3.edits); + linesToRemove.push(...result3.linesToRemove); + + // Case 4: Remove priority property from client.settings() options + edits.push(...removeSettingsPriority(rootNode)); + + if (!edits.length && linesToRemove.length === 0) return null; + + const sourceCode = rootNode.commitEdits(edits); + return removeLines(sourceCode, linesToRemove); +} + +/** + * Remove priority property from http2.connect() call options + */ +function removeConnectPriority(rootNode: SgNode): Edit[] { + const edits: Edit[] = []; + + // Match any connect() call + const connectCalls = rootNode.findAll({ + rule: { + pattern: "$HTTP2.connect($$$ARGS)", + }, + }); + + for (const call of connectCalls) { + const objects = call.findAll({ + rule: { + kind: "object", + }, + }); + + for (const obj of objects) { + // Check if object only contains priority properties + // Get immediate children pairs only (not nested) + const pairs = obj.children().filter((child) => child.kind() === "pair"); + + let hasPriority = false; + let allPriority = true; + + for (const pair of pairs) { + const keyNode = pair.find({ + rule: { + kind: "property_identifier", + regex: "^priority$", + }, + }); + + if (keyNode) { + hasPriority = true; + } else { + allPriority = false; + } + } + + if (allPriority && hasPriority) { + // Remove the entire object argument from the call + const callText = call.text(); + const objText = obj.text(); + // Use s flag to match across newlines (including the multiline object) + const cleanedCall = callText.replace(new RegExp(`(,\\s*)?${escapeRegex(objText)}(,\\s*)?`, "s"), ""); + const finalCall = cleanedCall.replace(/,\s*\)/, ")"); + + if (finalCall !== callText) { + edits.push(call.replace(finalCall)); + } + } else if (hasPriority) { + // Object has other properties, so just remove priority pair + edits.push(...removePriorityPairFromObject(obj)); + } + } + } + + return edits; +} + +/** + * Remove priority property from session.request() call options + */ +function removeRequestPriority(rootNode: SgNode): Edit[] { + const edits: Edit[] = []; + + // Find all request calls and clean priority from their options + const requestCalls = rootNode.findAll({ + rule: { + pattern: "$SESSION.request($$$_ARGS)", + }, + }); + + for (const call of requestCalls) { + const objects = call.findAll({ + rule: { + kind: "object", + }, + }); + + for (const obj of objects) { + // Check if object only contains priority properties + // Get immediate children pairs only (not nested) + const pairs = obj.children().filter((child) => child.kind() === "pair"); + + let hasPriority = false; + let allPriority = true; + + for (const pair of pairs) { + const keyNode = pair.find({ + rule: { + kind: "property_identifier", + regex: "^priority$", + }, + }); + + if (keyNode) { + hasPriority = true; + } else { + allPriority = false; + } + } + + if (allPriority && hasPriority) { + // Remove the entire object argument from the call + const callText = call.text(); + const objText = obj.text(); + const cleanedCall = callText.replace(new RegExp(`(,\\s*)?${escapeRegex(objText)}(,\\s*)?`, "s"), ""); + const finalCall = cleanedCall.replace(/,\s*\)/, ")"); + + if (finalCall !== callText) { + edits.push(call.replace(finalCall)); + } + } else if (hasPriority) { + // Object has other properties, so just remove priority pair + edits.push(...removePriorityPairFromObject(obj)); + } + } + } + + return edits; +} + +/** + * Remove entire stream.priority() method calls + */ +function removePriorityMethodCalls( + rootNode: SgNode, +): { edits: Edit[]; linesToRemove: Range[] } { + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + const priorityCalls = rootNode.findAll({ + rule: { + pattern: "$STREAM.priority($$$ARGS)", + }, + }); + + for (const call of priorityCalls) { + // Find the statement containing this call and remove it with its newline + let node: SgNode | undefined = call; + while (node) { + const parent = node.parent(); + const parentKind = parent?.kind(); + + // Found the statement level + if (parentKind === "expression_statement") { + // Add this line to the lines to remove + linesToRemove.push(parent!.range()); + break; + } + + node = parent; + } + } + + return { edits, linesToRemove }; +} + +/** + * Remove priority from settings() call + */ +function removeSettingsPriority(rootNode: SgNode): Edit[] { + const edits: Edit[] = []; + + const settingsCalls = rootNode.findAll({ + rule: { + pattern: "$SESSION.settings($$$_ARGS)", + }, + }); + + for (const call of settingsCalls) { + const objects = call.findAll({ + rule: { + kind: "object", + }, + }); + + for (const obj of objects) { + // Check if object only contains priority properties + // Get immediate children pairs only (not nested) + const pairs = obj.children().filter((child) => child.kind() === "pair"); + + let hasPriority = false; + let allPriority = true; + + for (const pair of pairs) { + const keyNode = pair.find({ + rule: { + kind: "property_identifier", + regex: "^priority$", + }, + }); + + if (keyNode) { + hasPriority = true; + } else { + allPriority = false; + } + } + + if (allPriority && hasPriority) { + // Remove the entire object argument from the call + const callText = call.text(); + const objText = obj.text(); + const cleanedCall = callText.replace(new RegExp(`(,\\s*)?${escapeRegex(objText)}(,\\s*)?`, "s"), ""); + const finalCall = cleanedCall.replace(/,\s*\)/, ")"); + + if (finalCall !== callText) { + edits.push(call.replace(finalCall)); + } + } else if (hasPriority) { + // Object has other properties, so just remove priority pair + edits.push(...removePriorityPairFromObject(obj)); + } + } + } + + return edits; +} + +/** + * Find and remove priority pair from an object, handling commas properly + */ +function removePriorityPairFromObject(obj: SgNode): Edit[] { + const edits: Edit[] = []; + + const pairs = obj.findAll({ + rule: { + kind: "pair", + }, + }); + + // Find all priority pairs + const priorityPairs: SgNode[] = []; + for (const pair of pairs) { + const keyNode = pair.find({ + rule: { + kind: "property_identifier", + regex: "^priority$", + }, + }); + + if (keyNode) { + priorityPairs.push(pair); + } + } + + if (priorityPairs.length === 0) { + return edits; + } + + // If all pairs are priority, remove the entire object + if (priorityPairs.length === pairs.length) { + edits.push(obj.replace("")); + return edits; + } + + // Otherwise, we need to remove pairs and clean up commas + // Strategy: replace the object with a cleaned version + const objText = obj.text(); + let result = objText; + + // For each priority pair, remove it along with associated comma + for (const pair of priorityPairs) { + const pairText = pair.text(); + + // Try to match and remove: ", priority: {...}" or similar + // First try with leading comma + const leadingCommaPattern = `,\\s*${escapeRegex(pairText)}`; + if (result.includes(",") && result.includes(pairText)) { + result = result.replace(new RegExp(leadingCommaPattern), ""); + } + + // If still not removed, try with trailing comma + if (result.includes(pairText)) { + const trailingCommaPattern = `${escapeRegex(pairText)},`; + result = result.replace(new RegExp(trailingCommaPattern), ""); + } + + // If still not removed, just remove the pair + if (result.includes(pairText)) { + result = result.replace(pairText, ""); + } + } + + // Clean up any resulting spacing issues + result = result.replace(/,\s*,/g, ","); + result = result.replace(/{\s*,/g, "{"); + result = result.replace(/,\s*}/g, "}"); + result = result.replace(/{(\S)/g, "{ $1"); + result = result.replace(/(\S)}/g, "$1 }"); + + if (result !== objText) { + edits.push(obj.replace(result)); + } + + return edits; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/recipes/http2-priority-signaling/tests/expected/case1-connect-priority.js b/recipes/http2-priority-signaling/tests/expected/case1-connect-priority.js new file mode 100644 index 00000000..2e9c114d --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case1-connect-priority.js @@ -0,0 +1,2 @@ +const http2 = require("node:http2"); +const session = http2.connect("https://example.com"); diff --git a/recipes/http2-priority-signaling/tests/expected/case2-request-priority.js b/recipes/http2-priority-signaling/tests/expected/case2-request-priority.js new file mode 100644 index 00000000..a307a8d1 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case2-request-priority.js @@ -0,0 +1,4 @@ +const http2 = require("node:http2"); +const stream = session.request({ + ":path": "/api/data" +}); diff --git a/recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js b/recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js new file mode 100644 index 00000000..ff4a2e31 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js @@ -0,0 +1 @@ +const http2 = require("node:http2"); diff --git a/recipes/http2-priority-signaling/tests/expected/case4-settings-priority-esm.mjs b/recipes/http2-priority-signaling/tests/expected/case4-settings-priority-esm.mjs new file mode 100644 index 00000000..d14019cf --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case4-settings-priority-esm.mjs @@ -0,0 +1,3 @@ +import http2 from "node:http2"; +const client = http2.connect("https://example.com"); +client.settings({ enablePush: false }); diff --git a/recipes/http2-priority-signaling/tests/input/case1-connect-priority.js b/recipes/http2-priority-signaling/tests/input/case1-connect-priority.js new file mode 100644 index 00000000..de8e7bad --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case1-connect-priority.js @@ -0,0 +1,8 @@ +const http2 = require("node:http2"); +const session = http2.connect("https://example.com", { + priority: { + weight: 16, + parent: 0, + exclusive: false + } +}); diff --git a/recipes/http2-priority-signaling/tests/input/case2-request-priority.js b/recipes/http2-priority-signaling/tests/input/case2-request-priority.js new file mode 100644 index 00000000..2ea08793 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case2-request-priority.js @@ -0,0 +1,5 @@ +const http2 = require("node:http2"); +const stream = session.request({ + ":path": "/api/data", + priority: { weight: 32 } +}); diff --git a/recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js b/recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js new file mode 100644 index 00000000..6c9f968f --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js @@ -0,0 +1,6 @@ +const http2 = require("node:http2"); +stream.priority({ + exclusive: true, + parent: 0, + weight: 128 +}); diff --git a/recipes/http2-priority-signaling/tests/input/case4-settings-priority-esm.mjs b/recipes/http2-priority-signaling/tests/input/case4-settings-priority-esm.mjs new file mode 100644 index 00000000..47b8911c --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case4-settings-priority-esm.mjs @@ -0,0 +1,3 @@ +import http2 from "node:http2"; +const client = http2.connect("https://example.com"); +client.settings({ enablePush: false, priority: true }); diff --git a/recipes/http2-priority-signaling/workflow.yaml b/recipes/http2-priority-signaling/workflow.yaml new file mode 100644 index 00000000..4d7dac7f --- /dev/null +++ b/recipes/http2-priority-signaling/workflow.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Handle DEP0194 via removing HTTP/2 priority-related options and methods + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript From e539b2fe0e21546f89d460e602a5224141353f0d Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:58:09 +0200 Subject: [PATCH 2/7] Update workflow.ts --- recipes/http2-priority-signaling/src/workflow.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts index 3afc5470..a69c0d73 100644 --- a/recipes/http2-priority-signaling/src/workflow.ts +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -26,9 +26,10 @@ export default function transform(root: SgRoot): string | null { const linesToRemove: Range[] = []; // Get all http2 imports/requires - const http2Imports = getNodeImportStatements(root, "http2"); - const http2Requires = getNodeRequireCalls(root, "http2"); - const http2Statements = [...http2Imports, ...http2Requires]; + const http2Statements = [ + ...getNodeImportStatements(root, "http2"), + ...getNodeRequireCalls(root, "http2") + ]; if (!http2Statements.length) return null; @@ -49,6 +50,7 @@ export default function transform(root: SgRoot): string | null { if (!edits.length && linesToRemove.length === 0) return null; const sourceCode = rootNode.commitEdits(edits); + return removeLines(sourceCode, linesToRemove); } From 17c111ab856d23ee8c7f3fa7885f157b1a36ac47 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:03:28 +0100 Subject: [PATCH 3/7] Update workflow.ts --- .../http2-priority-signaling/src/workflow.ts | 136 +++++++++++++++--- 1 file changed, 113 insertions(+), 23 deletions(-) diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts index a69c0d73..44fd3038 100644 --- a/recipes/http2-priority-signaling/src/workflow.ts +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -10,16 +10,16 @@ import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-s import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; /* - * Transforms HTTP/2 priority-related options and methods. - * - * Steps: - * - * 1. Find all http2 imports and require calls - * 2. Find and remove priority property from connect() options - * 3. Find and remove priority property from request() options - * 4. Find and remove complete stream.priority() calls - * 5. Find and remove priority property from settings() options - */ +* Transforms HTTP/2 priority-related options and methods. +* +* Steps: +* +* 1. Find all http2 imports and require calls +* 2. Find and remove priority property from connect() options +* 3. Find and remove priority property from request() options +* 4. Find and remove complete stream.priority() calls +* 5. Find and remove priority property from settings() options +*/ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; @@ -190,22 +190,79 @@ function removePriorityMethodCalls( const edits: Edit[] = []; const linesToRemove: Range[] = []; - const priorityCalls = rootNode.findAll({ - rule: { - pattern: "$STREAM.priority($$$ARGS)", - }, + // Build a restricted set of "safe" priority() calls to remove. + // We only want to remove calls that we can reasonably verify are + // HTTP/2 streams created by `session.request()` or by + // `http2.connect(...).request()`. + + // 1) priority() called directly on the result of a request()-call + const chainedSessionPriorityCalls = rootNode.findAll({ + rule: { pattern: "$SESSION.request($$$_ARGS).priority($$$ARGS)" }, + }); + + // 2) priority() called directly on the result of http2.connect(...).request(...) + const chainedConnectPriorityCalls = rootNode.findAll({ + rule: { pattern: "$HTTP2.connect($$$_ARGS).request($$ARGS).priority($$$ARGS)" }, + }); + + // 3) find variables that are assigned from session.request(...) or + // http2.connect(...).request(...) so we can later match `stream.priority()` + const assignedFromSessionRequest = rootNode.findAll({ + rule: { pattern: "$NAME = $SESSION.request($$$_ARGS)" }, }); + const assignedFromConnectRequest = rootNode.findAll({ + rule: { pattern: "$NAME = $HTTP2.connect($$$_ARGS).request($$ARGS)" }, + }); + + const creatorNames = new Set(); + function addCreatorNames(nodes: SgNode[]) { + for (const n of nodes) { + const t = n.text(); + // Try to extract a simple identifier on the left-hand side. + // Matches: "const stream = ...", "let s = ...", "s = ..." + const m = t.match(/(?:const|let|var)?\s*([A-Za-z_$][\w$]*)\s*=/); + if (m) creatorNames.add(m[1]); + } + } + + addCreatorNames(assignedFromSessionRequest); + addCreatorNames(assignedFromConnectRequest); + + // 4) All other priority() calls we will inspect, but only accept those + // whose receiver is a simple identifier that we found in creatorNames. + const allPriorityCalls = rootNode.findAll({ + rule: { pattern: "$STREAM.priority($$$ARGS)" }, + }); + + // Consolidate safe calls into a single array (use Set to avoid dupes) + const safeCalls = new Set>([ + ...chainedSessionPriorityCalls, + ...chainedConnectPriorityCalls, + ]); + + for (const call of allPriorityCalls) { + const callText = call.text(); + // Try to capture simple identifier receivers like `stream.priority(...)`. + const m = callText.match(/^\s*([A-Za-z_$][\w$]*)\.priority\s*\(/); + // If the receiver is a simple identifier (e.g. `stream.priority(...)`), + // accept it as a safe call. This function is only invoked when the + // file contains an `http2` import/require (see transform), so a simple + // identifier receiver is likely an HTTP/2 Stream. + if (m) { + safeCalls.add(call); + } + } - for (const call of priorityCalls) { - // Find the statement containing this call and remove it with its newline + // Now remove only the safe calls (we still remove the containing + // expression statements as before). + for (const call of safeCalls) { let node: SgNode | undefined = call; + while (node) { const parent = node.parent(); const parentKind = parent?.kind(); - // Found the statement level if (parentKind === "expression_statement") { - // Add this line to the lines to remove linesToRemove.push(parent!.range()); break; } @@ -223,13 +280,46 @@ function removePriorityMethodCalls( function removeSettingsPriority(rootNode: SgNode): Edit[] { const edits: Edit[] = []; - const settingsCalls = rootNode.findAll({ - rule: { - pattern: "$SESSION.settings($$$_ARGS)", - }, + // Guardrails: only modify settings() when it's clearly an http2 session. + // Accept: + // - http2.connect(...).settings(...) + // - = http2.connect(...); .settings(...) + + const chainedConnectSettingsCalls = rootNode.findAll({ + rule: { pattern: "$HTTP2.connect($$$_ARGS).settings($$$_ARGS)" }, + }); + + const assignedFromConnect = rootNode.findAll({ + rule: { pattern: "$NAME = $HTTP2.connect($$$_ARGS)" }, }); - for (const call of settingsCalls) { + const creatorNames = new Set(); + for (const n of assignedFromConnect) { + const t = n.text(); + const m = t.match(/(?:const|let|var)?\s*([A-Za-z_$][\w$]*)\s*=/); + if (m) creatorNames.add(m[1]); + } + + // All settings() calls in the file + const allSettingsCalls = rootNode.findAll({ + rule: { pattern: "$SESSION.settings($$$_ARGS)" }, + }); + + const safeCalls = new Set>([...chainedConnectSettingsCalls]); + + for (const call of allSettingsCalls) { + const callText = call.text(); + const m = callText.match(/^\s*([A-Za-z_$][\w$]*)\.settings\s*\(/); + // Accept simple identifier receivers (e.g. `client.settings(...)`) as + // safe when http2 is present in the file — this matches common usage + // like `const client = http2.connect(...); client.settings(...)`. + if (m) { + safeCalls.add(call); + } + } + + // Process only safe settings() calls + for (const call of safeCalls) { const objects = call.findAll({ rule: { kind: "object", From 676ec7769af8fa19c366766dad8723d6d68c08a1 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 8 Nov 2025 11:11:02 +0100 Subject: [PATCH 4/7] fix --- .../http2-priority-signaling/src/workflow.ts | 414 +++++++++--------- .../tests/expected/case2-request-priority.js | 1 + .../expected/case3-stream-priority-method.js | 2 + .../tests/input/case2-request-priority.js | 1 + .../input/case3-stream-priority-method.js | 2 + 5 files changed, 219 insertions(+), 201 deletions(-) diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts index 44fd3038..a6339039 100644 --- a/recipes/http2-priority-signaling/src/workflow.ts +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -1,13 +1,33 @@ import type { - Edit, - Range, - SgNode, - SgRoot, + Edit, + Range, + SgNode, + SgRoot, } from "@codemod.com/jssg-types/main"; import type Js from "@codemod.com/jssg-types/langs/javascript"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; +// Lightweight lexical scope resolver (subset of functionality from PR #248 get-scope util) +// Ascends parents until it finds a block/function/program and returns the innermost block-like +// container to limit member searches to the variable's lexical region. +function getLexicalScope(node: SgNode): SgNode { + let current = node.parent(); + let candidate: SgNode | undefined; + while (current) { + const kind = current.kind(); + if (kind === "block" || kind === "program") { + candidate = current; + } + if (kind === "program") break; + current = current.parent(); + } + if (candidate) return candidate; + // Ascend to top-most ancestor (program) + let top: SgNode = node; + while (top.parent()) top = top.parent() as SgNode; + return top; +} /* * Transforms HTTP/2 priority-related options and methods. @@ -25,32 +45,51 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; const linesToRemove: Range[] = []; - // Get all http2 imports/requires + // Gather all http2 import/require statements. If none, abort early. const http2Statements = [ ...getNodeImportStatements(root, "http2"), - ...getNodeRequireCalls(root, "http2") + ...getNodeRequireCalls(root, "http2"), ]; - if (!http2Statements.length) return null; - // Case 1: Remove priority object from http2.connect() options + // Discover session variables created via http2.connect (await or not) by + // locating call expressions and climbing to the variable declarator. + const sessionVars: { name: string; decl: SgNode; scope: SgNode }[] = []; + const connectCalls = [ + ...rootNode.findAll({ rule: { pattern: "$HTTP2.connect($$$_ARGS)" } }), + ]; + for (const call of connectCalls) { + let n: SgNode | undefined = call; + while (n && n.kind() !== "variable_declarator") { + n = n.parent(); + } + if (!n) continue; + const nameNode = (n as SgNode).field("name"); + if (!nameNode) continue; + sessionVars.push({ name: nameNode.text(), decl: n, scope: getLexicalScope(n) }); + } + + // Case 1: Remove priority object from http2.connect() options (direct call sites) edits.push(...removeConnectPriority(rootNode)); - // Case 2: Remove priority object from session.request() options - edits.push(...removeRequestPriority(rootNode)); + // Case 2: Remove priority from session.request() options scoped to discovered session vars + chained connect().request. + edits.push(...removeRequestPriority(rootNode, sessionVars)); - // Case 3: Remove entire stream.priority() method calls - const result3 = removePriorityMethodCalls(rootNode); + // Determine stream variables created from session.request() or connect().request(). + const streamVars = collectStreamVars(rootNode, sessionVars); + + // Case 3: Remove entire stream.priority() calls only for: + // - chained request().priority() + // - variables assigned from session.request/connect().request + const result3 = removePriorityMethodCalls(rootNode, streamVars); edits.push(...result3.edits); linesToRemove.push(...result3.linesToRemove); - // Case 4: Remove priority property from client.settings() options - edits.push(...removeSettingsPriority(rootNode)); - - if (!edits.length && linesToRemove.length === 0) return null; + // Case 4: Remove priority property from session.settings() options scoped to session vars + chained connect().settings. + edits.push(...removeSettingsPriority(rootNode, sessionVars)); + if (!edits.length && !linesToRemove.length) return null; const sourceCode = rootNode.commitEdits(edits); - return removeLines(sourceCode, linesToRemove); } @@ -121,63 +160,64 @@ function removeConnectPriority(rootNode: SgNode): Edit[] { /** * Remove priority property from session.request() call options */ -function removeRequestPriority(rootNode: SgNode): Edit[] { +function removeRequestPriority( + rootNode: SgNode, + sessionVars: { name: string; scope: SgNode }[], +): Edit[] { const edits: Edit[] = []; - // Find all request calls and clean priority from their options - const requestCalls = rootNode.findAll({ - rule: { - pattern: "$SESSION.request($$$_ARGS)", - }, + // Chained connect().request(...) still safe regardless of variable binding. + const chained = rootNode.findAll({ + rule: { pattern: "$HTTP2.connect($$$_ARGS).request($$ARGS)" }, }); + const allCalls: SgNode[] = [...chained]; - for (const call of requestCalls) { + // Scoped session.request calls based on discovered session variables. + for (const sess of sessionVars) { + const calls = sess.scope.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + pattern: `${sess.name}.request`, + }, + }, + }); + allCalls.push(...calls); + } + + for (const call of allCalls) { const objects = call.findAll({ rule: { kind: "object", }, }); - for (const obj of objects) { - // Check if object only contains priority properties - // Get immediate children pairs only (not nested) const pairs = obj.children().filter((child) => child.kind() === "pair"); - let hasPriority = false; let allPriority = true; - for (const pair of pairs) { const keyNode = pair.find({ - rule: { - kind: "property_identifier", - regex: "^priority$", - }, + rule: { kind: "property_identifier", regex: "^priority$" }, }); - - if (keyNode) { - hasPriority = true; - } else { - allPriority = false; - } + if (keyNode) hasPriority = true; else allPriority = false; } - if (allPriority && hasPriority) { - // Remove the entire object argument from the call const callText = call.text(); const objText = obj.text(); - const cleanedCall = callText.replace(new RegExp(`(,\\s*)?${escapeRegex(objText)}(,\\s*)?`, "s"), ""); - const finalCall = cleanedCall.replace(/,\s*\)/, ")"); - - if (finalCall !== callText) { - edits.push(call.replace(finalCall)); - } + // Remove object argument (with potential surrounding commas) + let cleanedCall = callText; + cleanedCall = cleanedCall.replace(objText, ""); + cleanedCall = cleanedCall.replace(/,\s*\)/, ")"); + cleanedCall = cleanedCall.replace(/\(\s*,/, "("); + const finalCall = cleanedCall; + if (finalCall !== callText) edits.push(call.replace(finalCall)); } else if (hasPriority) { - // Object has other properties, so just remove priority pair edits.push(...removePriorityPairFromObject(obj)); } } } - return edits; } @@ -185,190 +225,162 @@ function removeRequestPriority(rootNode: SgNode): Edit[] { * Remove entire stream.priority() method calls */ function removePriorityMethodCalls( - rootNode: SgNode, + rootNode: SgNode, + streamVars: { name: string; scope: SgNode }[], ): { edits: Edit[]; linesToRemove: Range[] } { - const edits: Edit[] = []; - const linesToRemove: Range[] = []; - - // Build a restricted set of "safe" priority() calls to remove. - // We only want to remove calls that we can reasonably verify are - // HTTP/2 streams created by `session.request()` or by - // `http2.connect(...).request()`. - - // 1) priority() called directly on the result of a request()-call - const chainedSessionPriorityCalls = rootNode.findAll({ - rule: { pattern: "$SESSION.request($$$_ARGS).priority($$$ARGS)" }, - }); - - // 2) priority() called directly on the result of http2.connect(...).request(...) - const chainedConnectPriorityCalls = rootNode.findAll({ - rule: { pattern: "$HTTP2.connect($$$_ARGS).request($$ARGS).priority($$$ARGS)" }, - }); - - // 3) find variables that are assigned from session.request(...) or - // http2.connect(...).request(...) so we can later match `stream.priority()` - const assignedFromSessionRequest = rootNode.findAll({ - rule: { pattern: "$NAME = $SESSION.request($$$_ARGS)" }, - }); - const assignedFromConnectRequest = rootNode.findAll({ - rule: { pattern: "$NAME = $HTTP2.connect($$$_ARGS).request($$ARGS)" }, - }); - - const creatorNames = new Set(); - function addCreatorNames(nodes: SgNode[]) { - for (const n of nodes) { - const t = n.text(); - // Try to extract a simple identifier on the left-hand side. - // Matches: "const stream = ...", "let s = ...", "s = ..." - const m = t.match(/(?:const|let|var)?\s*([A-Za-z_$][\w$]*)\s*=/); - if (m) creatorNames.add(m[1]); - } - } - - addCreatorNames(assignedFromSessionRequest); - addCreatorNames(assignedFromConnectRequest); - - // 4) All other priority() calls we will inspect, but only accept those - // whose receiver is a simple identifier that we found in creatorNames. - const allPriorityCalls = rootNode.findAll({ - rule: { pattern: "$STREAM.priority($$$ARGS)" }, - }); - - // Consolidate safe calls into a single array (use Set to avoid dupes) - const safeCalls = new Set>([ - ...chainedSessionPriorityCalls, - ...chainedConnectPriorityCalls, - ]); - - for (const call of allPriorityCalls) { - const callText = call.text(); - // Try to capture simple identifier receivers like `stream.priority(...)`. - const m = callText.match(/^\s*([A-Za-z_$][\w$]*)\.priority\s*\(/); - // If the receiver is a simple identifier (e.g. `stream.priority(...)`), - // accept it as a safe call. This function is only invoked when the - // file contains an `http2` import/require (see transform), so a simple - // identifier receiver is likely an HTTP/2 Stream. - if (m) { - safeCalls.add(call); - } - } - - // Now remove only the safe calls (we still remove the containing - // expression statements as before). - for (const call of safeCalls) { - let node: SgNode | undefined = call; - - while (node) { - const parent = node.parent(); - const parentKind = parent?.kind(); - - if (parentKind === "expression_statement") { - linesToRemove.push(parent!.range()); - break; - } - - node = parent; - } - } - - return { edits, linesToRemove }; + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + // Chained request(...).priority(...) directly (session or connect chains) + const chained = rootNode.findAll({ + rule: { pattern: "$SESSION.request($$$_ARGS).priority($$$ARGS)" }, + }); + const chainedConnect = rootNode.findAll({ + rule: { pattern: "$HTTP2.connect($$$_ARGS).request($$ARGS).priority($$$ARGS)" }, + }); + + const safeCalls = new Set>([...chained, ...chainedConnect]); + + // Priority on identified stream variable names within their scope. + for (const stream of streamVars) { + const calls = stream.scope.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + pattern: `${stream.name}.priority`, + }, + }, + }); + for (const c of calls) safeCalls.add(c); + } + + // Remove expression statements containing safe priority calls. + for (const call of safeCalls) { + let node: SgNode | undefined = call; + while (node) { + const parent = node.parent(); + if (parent?.kind() === "expression_statement") { + linesToRemove.push(parent.range()); + break; + } + node = parent; + } + } + return { edits, linesToRemove }; } /** * Remove priority from settings() call */ -function removeSettingsPriority(rootNode: SgNode): Edit[] { +function removeSettingsPriority( + rootNode: SgNode, + sessionVars: { name: string; scope: SgNode }[], +): Edit[] { const edits: Edit[] = []; - - // Guardrails: only modify settings() when it's clearly an http2 session. - // Accept: - // - http2.connect(...).settings(...) - // - = http2.connect(...); .settings(...) - - const chainedConnectSettingsCalls = rootNode.findAll({ + // Chained connect().settings(...) + const chained = rootNode.findAll({ rule: { pattern: "$HTTP2.connect($$$_ARGS).settings($$$_ARGS)" }, }); - const assignedFromConnect = rootNode.findAll({ - rule: { pattern: "$NAME = $HTTP2.connect($$$_ARGS)" }, - }); - - const creatorNames = new Set(); - for (const n of assignedFromConnect) { - const t = n.text(); - const m = t.match(/(?:const|let|var)?\s*([A-Za-z_$][\w$]*)\s*=/); - if (m) creatorNames.add(m[1]); - } - - // All settings() calls in the file - const allSettingsCalls = rootNode.findAll({ - rule: { pattern: "$SESSION.settings($$$_ARGS)" }, - }); - - const safeCalls = new Set>([...chainedConnectSettingsCalls]); + const safeCalls = new Set>([...chained]); - for (const call of allSettingsCalls) { - const callText = call.text(); - const m = callText.match(/^\s*([A-Za-z_$][\w$]*)\.settings\s*\(/); - // Accept simple identifier receivers (e.g. `client.settings(...)`) as - // safe when http2 is present in the file — this matches common usage - // like `const client = http2.connect(...); client.settings(...)`. - if (m) { - safeCalls.add(call); - } - } - - // Process only safe settings() calls - for (const call of safeCalls) { - const objects = call.findAll({ + // Scoped session.settings calls for discovered session variables. + for (const sess of sessionVars) { + const calls = sess.scope.findAll({ rule: { - kind: "object", + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + pattern: `${sess.name}.settings`, + }, }, }); + for (const c of calls) safeCalls.add(c); + } + for (const call of safeCalls) { + const objects = call.findAll({ rule: { kind: "object" } }); for (const obj of objects) { - // Check if object only contains priority properties - // Get immediate children pairs only (not nested) const pairs = obj.children().filter((child) => child.kind() === "pair"); - let hasPriority = false; let allPriority = true; - for (const pair of pairs) { - const keyNode = pair.find({ - rule: { - kind: "property_identifier", - regex: "^priority$", - }, - }); - - if (keyNode) { - hasPriority = true; - } else { - allPriority = false; - } + const keyNode = pair.find({ rule: { kind: "property_identifier", regex: "^priority$" } }); + if (keyNode) hasPriority = true; else allPriority = false; } - if (allPriority && hasPriority) { - // Remove the entire object argument from the call const callText = call.text(); const objText = obj.text(); - const cleanedCall = callText.replace(new RegExp(`(,\\s*)?${escapeRegex(objText)}(,\\s*)?`, "s"), ""); - const finalCall = cleanedCall.replace(/,\s*\)/, ")"); - - if (finalCall !== callText) { - edits.push(call.replace(finalCall)); - } + let cleanedCall = callText; + cleanedCall = cleanedCall.replace(objText, ""); + cleanedCall = cleanedCall.replace(/,\s*\)/, ")"); + cleanedCall = cleanedCall.replace(/\(\s*,/, "("); + const finalCall = cleanedCall; + if (finalCall !== callText) edits.push(call.replace(finalCall)); } else if (hasPriority) { - // Object has other properties, so just remove priority pair edits.push(...removePriorityPairFromObject(obj)); } } } - return edits; } +// Collect stream variables created from session.request() or connect().request() patterns. +function collectStreamVars( + rootNode: SgNode, + sessionVars: { name: string; scope: SgNode }[], +): { name: string; scope: SgNode }[] { + const streamVars: { name: string; scope: SgNode }[] = []; + // From sessionVar.request(...) + for (const sess of sessionVars) { + const decls = sess.scope.findAll({ + rule: { + kind: "variable_declarator", + has: { + field: "value", + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + pattern: `${sess.name}.request`, + }, + }, + }, + }); + for (const d of decls) { + const nameNode = (d as SgNode).field("name"); + streamVars.push({ name: nameNode.text(), scope: getLexicalScope(d) }); + } + } + // From connect().request(...) chained assignments. + const chainedDecls = rootNode.findAll({ + rule: { + kind: "variable_declarator", + has: { + field: "value", + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + pattern: "request", // we will validate parent chain text + }, + }, + }, + }); + for (const d of chainedDecls) { + const valueText = (d as SgNode).field("value").text(); + // Quick heuristic: contains ".connect(" before ".request(". + if (/connect\s*\([^)]*\).*\.request\s*\(/.test(valueText)) { + const nameNode = (d as SgNode).field("name"); + streamVars.push({ name: nameNode.text(), scope: getLexicalScope(d) }); + } + } + return streamVars; +} + /** * Find and remove priority pair from an object, handling commas properly */ diff --git a/recipes/http2-priority-signaling/tests/expected/case2-request-priority.js b/recipes/http2-priority-signaling/tests/expected/case2-request-priority.js index a307a8d1..971e5117 100644 --- a/recipes/http2-priority-signaling/tests/expected/case2-request-priority.js +++ b/recipes/http2-priority-signaling/tests/expected/case2-request-priority.js @@ -1,4 +1,5 @@ const http2 = require("node:http2"); +const session = http2.connect("https://example.com"); const stream = session.request({ ":path": "/api/data" }); diff --git a/recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js b/recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js index ff4a2e31..e21ea359 100644 --- a/recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js +++ b/recipes/http2-priority-signaling/tests/expected/case3-stream-priority-method.js @@ -1 +1,3 @@ const http2 = require("node:http2"); +const session = http2.connect("https://example.com"); +const stream = session.request({ ":path": "/" }); diff --git a/recipes/http2-priority-signaling/tests/input/case2-request-priority.js b/recipes/http2-priority-signaling/tests/input/case2-request-priority.js index 2ea08793..f2d57dc8 100644 --- a/recipes/http2-priority-signaling/tests/input/case2-request-priority.js +++ b/recipes/http2-priority-signaling/tests/input/case2-request-priority.js @@ -1,4 +1,5 @@ const http2 = require("node:http2"); +const session = http2.connect("https://example.com"); const stream = session.request({ ":path": "/api/data", priority: { weight: 32 } diff --git a/recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js b/recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js index 6c9f968f..e7cc4bad 100644 --- a/recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js +++ b/recipes/http2-priority-signaling/tests/input/case3-stream-priority-method.js @@ -1,4 +1,6 @@ const http2 = require("node:http2"); +const session = http2.connect("https://example.com"); +const stream = session.request({ ":path": "/" }); stream.priority({ exclusive: true, parent: 0, From 5426e07353161a4b256c612801c4073f8d2e539c Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 8 Nov 2025 11:28:49 +0100 Subject: [PATCH 5/7] WIP --- .../http2-priority-signaling/src/workflow.ts | 18 ++++++++++++++++-- .../tests/expected/case10-import-namespace.mjs | 5 +++++ .../tests/expected/case11-import-connect.mjs | 5 +++++ .../expected/case12-import-connect-alias.mjs | 5 +++++ .../tests/expected/case5-dynamic-import.mjs | 6 ++++++ .../expected/case6-dynamic-import-await.mjs | 5 +++++ .../tests/expected/case7-require-myHttp2.js | 5 +++++ .../tests/expected/case8-require-connect.js | 5 +++++ .../expected/case9-require-connect-alias.js | 5 +++++ .../tests/input/case10-import-namespace.mjs | 6 ++++++ .../tests/input/case11-import-connect.mjs | 6 ++++++ .../input/case12-import-connect-alias.mjs | 6 ++++++ .../tests/input/case5-dynamic-import.mjs | 7 +++++++ .../tests/input/case6-dynamic-import-await.mjs | 6 ++++++ .../tests/input/case7-require-myHttp2.js | 6 ++++++ .../tests/input/case8-require-connect.js | 6 ++++++ .../tests/input/case9-require-connect-alias.js | 6 ++++++ 17 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs create mode 100644 recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs create mode 100644 recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs create mode 100644 recipes/http2-priority-signaling/tests/expected/case5-dynamic-import.mjs create mode 100644 recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs create mode 100644 recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js create mode 100644 recipes/http2-priority-signaling/tests/expected/case8-require-connect.js create mode 100644 recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js create mode 100644 recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs create mode 100644 recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs create mode 100644 recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs create mode 100644 recipes/http2-priority-signaling/tests/input/case5-dynamic-import.mjs create mode 100644 recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs create mode 100644 recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js create mode 100644 recipes/http2-priority-signaling/tests/input/case8-require-connect.js create mode 100644 recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts index a6339039..ad2a65a3 100644 --- a/recipes/http2-priority-signaling/src/workflow.ts +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -6,7 +6,7 @@ import type { } from "@codemod.com/jssg-types/main"; import type Js from "@codemod.com/jssg-types/langs/javascript"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; -import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeImportStatements, getNodeImportCalls } from "@nodejs/codemod-utils/ast-grep/import-statement"; import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; // Lightweight lexical scope resolver (subset of functionality from PR #248 get-scope util) // Ascends parents until it finds a block/function/program and returns the innermost block-like @@ -45,10 +45,11 @@ export default function transform(root: SgRoot): string | null { const edits: Edit[] = []; const linesToRemove: Range[] = []; - // Gather all http2 import/require statements. If none, abort early. + // Gather all http2 import/require statements/calls const http2Statements = [ ...getNodeImportStatements(root, "http2"), ...getNodeRequireCalls(root, "http2"), + ...getNodeImportCalls(root, "http2"), ]; if (!http2Statements.length) return null; @@ -69,6 +70,19 @@ export default function transform(root: SgRoot): string | null { sessionVars.push({ name: nameNode.text(), decl: n, scope: getLexicalScope(n) }); } + // Handle dynamic imports of http2 + const dynamicHttp2Imports = getNodeImportCalls(root, "http2"); + for (const importNode of dynamicHttp2Imports) { + const binding = importNode.field("name"); + if (binding) { + sessionVars.push({ + name: binding.text(), + decl: importNode, + scope: getLexicalScope(importNode), + }); + } + } + // Case 1: Remove priority object from http2.connect() options (direct call sites) edits.push(...removeConnectPriority(rootNode)); diff --git a/recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs b/recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs new file mode 100644 index 00000000..304f0952 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs @@ -0,0 +1,5 @@ +import * as foo from "node:http2"; +const session = foo.connect("https://example.com"); +session.settings({ + enablePush: true +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs b/recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs new file mode 100644 index 00000000..d735082a --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs @@ -0,0 +1,5 @@ +import { connect } from "node:http2"; +const session = connect("https://example.com"); +session.settings({ + enablePush: true +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs b/recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs new file mode 100644 index 00000000..c5571604 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs @@ -0,0 +1,5 @@ +import { connect as bar } from "node:http2"; +const session = bar("https://example.com"); +session.settings({ + enablePush: true +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/expected/case5-dynamic-import.mjs b/recipes/http2-priority-signaling/tests/expected/case5-dynamic-import.mjs new file mode 100644 index 00000000..d12f1455 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case5-dynamic-import.mjs @@ -0,0 +1,6 @@ +import("node:http2").then((http2) => { + const session = http2.connect("https://example.com"); + session.settings({ + enablePush: true + }); +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs b/recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs new file mode 100644 index 00000000..2f55bbe5 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs @@ -0,0 +1,5 @@ +const http2 = await import("node:http2"); +const session = http2.connect("https://example.com"); +session.settings({ + enablePush: true +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js b/recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js new file mode 100644 index 00000000..024af2dc --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js @@ -0,0 +1,5 @@ +const myHttp2 = require("http2"); +const session = myHttp2.connect("https://example.com"); +session.settings({ + enablePush: true +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/expected/case8-require-connect.js b/recipes/http2-priority-signaling/tests/expected/case8-require-connect.js new file mode 100644 index 00000000..39545416 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case8-require-connect.js @@ -0,0 +1,5 @@ +const { connect } = require("http2"); +const session = connect("https://example.com"); +session.settings({ + enablePush: true +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js b/recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js new file mode 100644 index 00000000..940abcda --- /dev/null +++ b/recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js @@ -0,0 +1,5 @@ +const { connect: bar } = require("http2"); +const session = bar("https://example.com"); +session.settings({ + enablePush: true +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs b/recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs new file mode 100644 index 00000000..0961cf8e --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs @@ -0,0 +1,6 @@ +import * as foo from "node:http2"; +const session = foo.connect("https://example.com"); +session.settings({ + enablePush: true, + priority: { weight: 16 } +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs b/recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs new file mode 100644 index 00000000..0fead604 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs @@ -0,0 +1,6 @@ +import { connect } from "node:http2"; +const session = connect("https://example.com"); +session.settings({ + enablePush: true, + priority: { weight: 16 } +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs b/recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs new file mode 100644 index 00000000..da3dc534 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs @@ -0,0 +1,6 @@ +import { connect as bar } from "node:http2"; +const session = bar("https://example.com"); +session.settings({ + enablePush: true, + priority: { weight: 16 } +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case5-dynamic-import.mjs b/recipes/http2-priority-signaling/tests/input/case5-dynamic-import.mjs new file mode 100644 index 00000000..9b1cd0d2 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case5-dynamic-import.mjs @@ -0,0 +1,7 @@ +import("node:http2").then((http2) => { + const session = http2.connect("https://example.com"); + session.settings({ + enablePush: true, + priority: { weight: 16 } + }); +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs b/recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs new file mode 100644 index 00000000..1f550f3e --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs @@ -0,0 +1,6 @@ +const http2 = await import("node:http2"); +const session = http2.connect("https://example.com"); +session.settings({ + enablePush: true, + priority: { weight: 16 } +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js b/recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js new file mode 100644 index 00000000..c0e60b30 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js @@ -0,0 +1,6 @@ +const myHttp2 = require("http2"); +const session = myHttp2.connect("https://example.com"); +session.settings({ + enablePush: true, + priority: { weight: 16 } +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case8-require-connect.js b/recipes/http2-priority-signaling/tests/input/case8-require-connect.js new file mode 100644 index 00000000..b3d9aea3 --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case8-require-connect.js @@ -0,0 +1,6 @@ +const { connect } = require("http2"); +const session = connect("https://example.com"); +session.settings({ + enablePush: true, + priority: { weight: 16 } +}); \ No newline at end of file diff --git a/recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js b/recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js new file mode 100644 index 00000000..43902c0d --- /dev/null +++ b/recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js @@ -0,0 +1,6 @@ +const { connect: bar } = require("http2"); +const session = bar("https://example.com"); +session.settings({ + enablePush: true, + priority: { weight: 16 } +}); \ No newline at end of file From 6946d109853743110b253b1b35bc3a76eb2671df Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 8 Nov 2025 11:45:51 +0100 Subject: [PATCH 6/7] update --- .../http2-priority-signaling/src/workflow.ts | 31 +++++++++++++++++-- .../expected/case10-import-namespace.mjs | 2 +- .../tests/expected/case11-import-connect.mjs | 2 +- .../expected/case12-import-connect-alias.mjs | 2 +- .../expected/case6-dynamic-import-await.mjs | 2 +- .../tests/expected/case7-require-myHttp2.js | 2 +- .../tests/expected/case8-require-connect.js | 2 +- .../expected/case9-require-connect-alias.js | 2 +- .../tests/input/case10-import-namespace.mjs | 2 +- .../tests/input/case11-import-connect.mjs | 2 +- .../input/case12-import-connect-alias.mjs | 2 +- .../input/case6-dynamic-import-await.mjs | 2 +- .../tests/input/case7-require-myHttp2.js | 2 +- .../tests/input/case8-require-connect.js | 2 +- .../input/case9-require-connect-alias.js | 2 +- 15 files changed, 43 insertions(+), 16 deletions(-) diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts index ad2a65a3..65536842 100644 --- a/recipes/http2-priority-signaling/src/workflow.ts +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -7,6 +7,7 @@ import type { import type Js from "@codemod.com/jssg-types/langs/javascript"; import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; import { getNodeImportStatements, getNodeImportCalls } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; // Lightweight lexical scope resolver (subset of functionality from PR #248 get-scope util) // Ascends parents until it finds a block/function/program and returns the innermost block-like @@ -53,12 +54,35 @@ export default function transform(root: SgRoot): string | null { ]; if (!http2Statements.length) return null; - // Discover session variables created via http2.connect (await or not) by + // Debug: Log all http2 statements (disabled in production) + // console.log("http2Statements:", http2Statements.map(stmt => stmt.text())); + + // Resolve all local callee names for http2.connect (handles namespace, default, named, alias, require/import) + const connectCallees = new Set(); + for (const stmt of http2Statements) { + try { + const resolved = resolveBindingPath(stmt, "$.connect"); + if (resolved) connectCallees.add(resolved); + } catch { + // ignore unsupported node kinds + } + } + + // Discover session variables created via http2.connect or destructured connect calls by // locating call expressions and climbing to the variable declarator. const sessionVars: { name: string; decl: SgNode; scope: SgNode }[] = []; - const connectCalls = [ + const connectCalls: SgNode[] = [ ...rootNode.findAll({ rule: { pattern: "$HTTP2.connect($$$_ARGS)" } }), + // Also include direct calls when `connect` is imported as a named binding or alias (e.g., `connect(...)` or `bar(...)`). + ...Array.from(connectCallees).flatMap((callee) => { + // If callee already includes a dot (e.g., http2.connect), the pattern above already matches it. + if (callee.includes(".")) return [] as SgNode[]; + return rootNode.findAll({ rule: { pattern: `${callee}($$$_ARGS)` } }); + }), ]; + + // Debug: Log all connect calls (disabled in production) + // console.log("connectCalls:", connectCalls.map(call => call.text())); for (const call of connectCalls) { let n: SgNode | undefined = call; while (n && n.kind() !== "variable_declarator") { @@ -89,6 +113,9 @@ export default function transform(root: SgRoot): string | null { // Case 2: Remove priority from session.request() options scoped to discovered session vars + chained connect().request. edits.push(...removeRequestPriority(rootNode, sessionVars)); + // Debug: Log session variables (disabled in production) + // console.log("sessionVars:", sessionVars.map(sess => ({ name: sess.name, scope: sess.scope.text() }))); + // Determine stream variables created from session.request() or connect().request(). const streamVars = collectStreamVars(rootNode, sessionVars); diff --git a/recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs b/recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs index 304f0952..55c03124 100644 --- a/recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs +++ b/recipes/http2-priority-signaling/tests/expected/case10-import-namespace.mjs @@ -2,4 +2,4 @@ import * as foo from "node:http2"; const session = foo.connect("https://example.com"); session.settings({ enablePush: true -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs b/recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs index d735082a..d9970988 100644 --- a/recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs +++ b/recipes/http2-priority-signaling/tests/expected/case11-import-connect.mjs @@ -2,4 +2,4 @@ import { connect } from "node:http2"; const session = connect("https://example.com"); session.settings({ enablePush: true -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs b/recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs index c5571604..55380e67 100644 --- a/recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs +++ b/recipes/http2-priority-signaling/tests/expected/case12-import-connect-alias.mjs @@ -2,4 +2,4 @@ import { connect as bar } from "node:http2"; const session = bar("https://example.com"); session.settings({ enablePush: true -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs b/recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs index 2f55bbe5..903aa689 100644 --- a/recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs +++ b/recipes/http2-priority-signaling/tests/expected/case6-dynamic-import-await.mjs @@ -2,4 +2,4 @@ const http2 = await import("node:http2"); const session = http2.connect("https://example.com"); session.settings({ enablePush: true -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js b/recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js index 024af2dc..864b4111 100644 --- a/recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js +++ b/recipes/http2-priority-signaling/tests/expected/case7-require-myHttp2.js @@ -2,4 +2,4 @@ const myHttp2 = require("http2"); const session = myHttp2.connect("https://example.com"); session.settings({ enablePush: true -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/expected/case8-require-connect.js b/recipes/http2-priority-signaling/tests/expected/case8-require-connect.js index 39545416..b2eb4cd2 100644 --- a/recipes/http2-priority-signaling/tests/expected/case8-require-connect.js +++ b/recipes/http2-priority-signaling/tests/expected/case8-require-connect.js @@ -2,4 +2,4 @@ const { connect } = require("http2"); const session = connect("https://example.com"); session.settings({ enablePush: true -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js b/recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js index 940abcda..96525956 100644 --- a/recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js +++ b/recipes/http2-priority-signaling/tests/expected/case9-require-connect-alias.js @@ -2,4 +2,4 @@ const { connect: bar } = require("http2"); const session = bar("https://example.com"); session.settings({ enablePush: true -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs b/recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs index 0961cf8e..5a034232 100644 --- a/recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs +++ b/recipes/http2-priority-signaling/tests/input/case10-import-namespace.mjs @@ -3,4 +3,4 @@ const session = foo.connect("https://example.com"); session.settings({ enablePush: true, priority: { weight: 16 } -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs b/recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs index 0fead604..719abde2 100644 --- a/recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs +++ b/recipes/http2-priority-signaling/tests/input/case11-import-connect.mjs @@ -3,4 +3,4 @@ const session = connect("https://example.com"); session.settings({ enablePush: true, priority: { weight: 16 } -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs b/recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs index da3dc534..f2678568 100644 --- a/recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs +++ b/recipes/http2-priority-signaling/tests/input/case12-import-connect-alias.mjs @@ -3,4 +3,4 @@ const session = bar("https://example.com"); session.settings({ enablePush: true, priority: { weight: 16 } -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs b/recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs index 1f550f3e..52085605 100644 --- a/recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs +++ b/recipes/http2-priority-signaling/tests/input/case6-dynamic-import-await.mjs @@ -3,4 +3,4 @@ const session = http2.connect("https://example.com"); session.settings({ enablePush: true, priority: { weight: 16 } -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js b/recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js index c0e60b30..f8aa4670 100644 --- a/recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js +++ b/recipes/http2-priority-signaling/tests/input/case7-require-myHttp2.js @@ -3,4 +3,4 @@ const session = myHttp2.connect("https://example.com"); session.settings({ enablePush: true, priority: { weight: 16 } -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/input/case8-require-connect.js b/recipes/http2-priority-signaling/tests/input/case8-require-connect.js index b3d9aea3..8ee32803 100644 --- a/recipes/http2-priority-signaling/tests/input/case8-require-connect.js +++ b/recipes/http2-priority-signaling/tests/input/case8-require-connect.js @@ -3,4 +3,4 @@ const session = connect("https://example.com"); session.settings({ enablePush: true, priority: { weight: 16 } -}); \ No newline at end of file +}); diff --git a/recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js b/recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js index 43902c0d..45b0f136 100644 --- a/recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js +++ b/recipes/http2-priority-signaling/tests/input/case9-require-connect-alias.js @@ -3,4 +3,4 @@ const session = bar("https://example.com"); session.settings({ enablePush: true, priority: { weight: 16 } -}); \ No newline at end of file +}); From 3f6a3175a3ec9f929a6c53e1f1ab027a18d660c4 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 8 Nov 2025 11:56:39 +0100 Subject: [PATCH 7/7] clean --- .../http2-priority-signaling/src/workflow.ts | 315 +++++++++--------- utils/src/ast-grep/resolve-binding-path.ts | 2 +- 2 files changed, 165 insertions(+), 152 deletions(-) diff --git a/recipes/http2-priority-signaling/src/workflow.ts b/recipes/http2-priority-signaling/src/workflow.ts index 65536842..ba870e7c 100644 --- a/recipes/http2-priority-signaling/src/workflow.ts +++ b/recipes/http2-priority-signaling/src/workflow.ts @@ -1,26 +1,22 @@ -import type { - Edit, - Range, - SgNode, - SgRoot, -} from "@codemod.com/jssg-types/main"; -import type Js from "@codemod.com/jssg-types/langs/javascript"; -import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; -import { getNodeImportStatements, getNodeImportCalls } from "@nodejs/codemod-utils/ast-grep/import-statement"; -import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; -import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; -// Lightweight lexical scope resolver (subset of functionality from PR #248 get-scope util) -// Ascends parents until it finds a block/function/program and returns the innermost block-like -// container to limit member searches to the variable's lexical region. +import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { + getNodeImportStatements, + getNodeImportCalls, +} from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import { removeLines } from '@nodejs/codemod-utils/ast-grep/remove-lines'; + function getLexicalScope(node: SgNode): SgNode { let current = node.parent(); let candidate: SgNode | undefined; while (current) { const kind = current.kind(); - if (kind === "block" || kind === "program") { + if (kind === 'block' || kind === 'program') { candidate = current; } - if (kind === "program") break; + if (kind === 'program') break; current = current.parent(); } if (candidate) return candidate; @@ -31,73 +27,73 @@ function getLexicalScope(node: SgNode): SgNode { } /* -* Transforms HTTP/2 priority-related options and methods. -* -* Steps: -* -* 1. Find all http2 imports and require calls -* 2. Find and remove priority property from connect() options -* 3. Find and remove priority property from request() options -* 4. Find and remove complete stream.priority() calls -* 5. Find and remove priority property from settings() options -*/ + * Transforms HTTP/2 priority-related options and methods. + * + * Steps: + * + * 1. Find all http2 imports and require calls + * 2. Find and remove priority property from connect() options + * 3. Find and remove priority property from request() options + * 4. Find and remove complete stream.priority() calls + * 5. Find and remove priority property from settings() options + */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; const linesToRemove: Range[] = []; // Gather all http2 import/require statements/calls + const dynamicHttp2Imports = getNodeImportCalls(root, 'http2'); const http2Statements = [ - ...getNodeImportStatements(root, "http2"), - ...getNodeRequireCalls(root, "http2"), - ...getNodeImportCalls(root, "http2"), + ...getNodeImportStatements(root, 'http2'), + ...getNodeRequireCalls(root, 'http2'), + ...dynamicHttp2Imports, ]; - if (!http2Statements.length) return null; - // Debug: Log all http2 statements (disabled in production) - // console.log("http2Statements:", http2Statements.map(stmt => stmt.text())); + // If any import do nothing + if (!http2Statements.length) return null; // Resolve all local callee names for http2.connect (handles namespace, default, named, alias, require/import) const connectCallees = new Set(); for (const stmt of http2Statements) { - try { - const resolved = resolveBindingPath(stmt, "$.connect"); - if (resolved) connectCallees.add(resolved); - } catch { - // ignore unsupported node kinds - } + if (stmt.kind() === 'expression_statement') continue; // skip dynamic imports + + const resolved = resolveBindingPath(stmt, '$.connect'); + if (resolved) connectCallees.add(resolved); } // Discover session variables created via http2.connect or destructured connect calls by // locating call expressions and climbing to the variable declarator. - const sessionVars: { name: string; decl: SgNode; scope: SgNode }[] = []; + const sessionVars: { name: string; decl: SgNode; scope: SgNode }[] = + []; const connectCalls: SgNode[] = [ - ...rootNode.findAll({ rule: { pattern: "$HTTP2.connect($$$_ARGS)" } }), + ...rootNode.findAll({ rule: { pattern: '$HTTP2.connect($$$_ARGS)' } }), // Also include direct calls when `connect` is imported as a named binding or alias (e.g., `connect(...)` or `bar(...)`). ...Array.from(connectCallees).flatMap((callee) => { // If callee already includes a dot (e.g., http2.connect), the pattern above already matches it. - if (callee.includes(".")) return [] as SgNode[]; + if (callee.includes('.')) return [] as SgNode[]; return rootNode.findAll({ rule: { pattern: `${callee}($$$_ARGS)` } }); }), ]; - // Debug: Log all connect calls (disabled in production) - // console.log("connectCalls:", connectCalls.map(call => call.text())); for (const call of connectCalls) { let n: SgNode | undefined = call; - while (n && n.kind() !== "variable_declarator") { + while (n && n.kind() !== 'variable_declarator') { n = n.parent(); } if (!n) continue; - const nameNode = (n as SgNode).field("name"); + const nameNode = (n as SgNode).field('name'); if (!nameNode) continue; - sessionVars.push({ name: nameNode.text(), decl: n, scope: getLexicalScope(n) }); + sessionVars.push({ + name: nameNode.text(), + decl: n, + scope: getLexicalScope(n), + }); } // Handle dynamic imports of http2 - const dynamicHttp2Imports = getNodeImportCalls(root, "http2"); for (const importNode of dynamicHttp2Imports) { - const binding = importNode.field("name"); + const binding = importNode.field('name'); if (binding) { sessionVars.push({ name: binding.text(), @@ -113,9 +109,6 @@ export default function transform(root: SgRoot): string | null { // Case 2: Remove priority from session.request() options scoped to discovered session vars + chained connect().request. edits.push(...removeRequestPriority(rootNode, sessionVars)); - // Debug: Log session variables (disabled in production) - // console.log("sessionVars:", sessionVars.map(sess => ({ name: sess.name, scope: sess.scope.text() }))); - // Determine stream variables created from session.request() or connect().request(). const streamVars = collectStreamVars(rootNode, sessionVars); @@ -130,7 +123,9 @@ export default function transform(root: SgRoot): string | null { edits.push(...removeSettingsPriority(rootNode, sessionVars)); if (!edits.length && !linesToRemove.length) return null; + const sourceCode = rootNode.commitEdits(edits); + return removeLines(sourceCode, linesToRemove); } @@ -143,21 +138,21 @@ function removeConnectPriority(rootNode: SgNode): Edit[] { // Match any connect() call const connectCalls = rootNode.findAll({ rule: { - pattern: "$HTTP2.connect($$$ARGS)", + pattern: '$HTTP2.connect($$$ARGS)', }, }); for (const call of connectCalls) { const objects = call.findAll({ rule: { - kind: "object", + kind: 'object', }, }); for (const obj of objects) { // Check if object only contains priority properties // Get immediate children pairs only (not nested) - const pairs = obj.children().filter((child) => child.kind() === "pair"); + const pairs = obj.children().filter((child) => child.kind() === 'pair'); let hasPriority = false; let allPriority = true; @@ -165,8 +160,8 @@ function removeConnectPriority(rootNode: SgNode): Edit[] { for (const pair of pairs) { const keyNode = pair.find({ rule: { - kind: "property_identifier", - regex: "^priority$", + kind: 'property_identifier', + regex: '^priority$', }, }); @@ -182,8 +177,11 @@ function removeConnectPriority(rootNode: SgNode): Edit[] { const callText = call.text(); const objText = obj.text(); // Use s flag to match across newlines (including the multiline object) - const cleanedCall = callText.replace(new RegExp(`(,\\s*)?${escapeRegex(objText)}(,\\s*)?`, "s"), ""); - const finalCall = cleanedCall.replace(/,\s*\)/, ")"); + const cleanedCall = callText.replace( + new RegExp(`(,\\s*)?${escapeRegex(objText)}(,\\s*)?`, 's'), + '', + ); + const finalCall = cleanedCall.replace(/,\s*\)/, ')'); if (finalCall !== callText) { edits.push(call.replace(finalCall)); @@ -209,7 +207,7 @@ function removeRequestPriority( // Chained connect().request(...) still safe regardless of variable binding. const chained = rootNode.findAll({ - rule: { pattern: "$HTTP2.connect($$$_ARGS).request($$ARGS)" }, + rule: { pattern: '$HTTP2.connect($$$_ARGS).request($$ARGS)' }, }); const allCalls: SgNode[] = [...chained]; @@ -217,10 +215,10 @@ function removeRequestPriority( for (const sess of sessionVars) { const calls = sess.scope.findAll({ rule: { - kind: "call_expression", + kind: 'call_expression', has: { - field: "function", - kind: "member_expression", + field: 'function', + kind: 'member_expression', pattern: `${sess.name}.request`, }, }, @@ -231,27 +229,30 @@ function removeRequestPriority( for (const call of allCalls) { const objects = call.findAll({ rule: { - kind: "object", + kind: 'object', }, }); for (const obj of objects) { - const pairs = obj.children().filter((child) => child.kind() === "pair"); + const pairs = obj.children().filter((child) => child.kind() === 'pair'); + let hasPriority = false; let allPriority = true; + for (const pair of pairs) { const keyNode = pair.find({ - rule: { kind: "property_identifier", regex: "^priority$" }, + rule: { kind: 'property_identifier', regex: '^priority$' }, }); - if (keyNode) hasPriority = true; else allPriority = false; + if (keyNode) hasPriority = true; + else allPriority = false; } if (allPriority && hasPriority) { const callText = call.text(); const objText = obj.text(); // Remove object argument (with potential surrounding commas) let cleanedCall = callText; - cleanedCall = cleanedCall.replace(objText, ""); - cleanedCall = cleanedCall.replace(/,\s*\)/, ")"); - cleanedCall = cleanedCall.replace(/\(\s*,/, "("); + cleanedCall = cleanedCall.replace(objText, ''); + cleanedCall = cleanedCall.replace(/,\s*\)/, ')'); + cleanedCall = cleanedCall.replace(/\(\s*,/, '('); const finalCall = cleanedCall; if (finalCall !== callText) edits.push(call.replace(finalCall)); } else if (hasPriority) { @@ -266,50 +267,54 @@ function removeRequestPriority( * Remove entire stream.priority() method calls */ function removePriorityMethodCalls( - rootNode: SgNode, - streamVars: { name: string; scope: SgNode }[], + rootNode: SgNode, + streamVars: { name: string; scope: SgNode }[], ): { edits: Edit[]; linesToRemove: Range[] } { - const edits: Edit[] = []; - const linesToRemove: Range[] = []; - - // Chained request(...).priority(...) directly (session or connect chains) - const chained = rootNode.findAll({ - rule: { pattern: "$SESSION.request($$$_ARGS).priority($$$ARGS)" }, - }); - const chainedConnect = rootNode.findAll({ - rule: { pattern: "$HTTP2.connect($$$_ARGS).request($$ARGS).priority($$$ARGS)" }, - }); - - const safeCalls = new Set>([...chained, ...chainedConnect]); - - // Priority on identified stream variable names within their scope. - for (const stream of streamVars) { - const calls = stream.scope.findAll({ - rule: { - kind: "call_expression", - has: { - field: "function", - kind: "member_expression", - pattern: `${stream.name}.priority`, - }, - }, - }); - for (const c of calls) safeCalls.add(c); - } - - // Remove expression statements containing safe priority calls. - for (const call of safeCalls) { - let node: SgNode | undefined = call; - while (node) { - const parent = node.parent(); - if (parent?.kind() === "expression_statement") { - linesToRemove.push(parent.range()); - break; - } - node = parent; - } - } - return { edits, linesToRemove }; + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + // Chained request(...).priority(...) directly (session or connect chains) + const chained = rootNode.findAll({ + rule: { pattern: '$SESSION.request($$$_ARGS).priority($$$ARGS)' }, + }); + const chainedConnect = rootNode.findAll({ + rule: { + pattern: '$HTTP2.connect($$$_ARGS).request($$ARGS).priority($$$ARGS)', + }, + }); + + const safeCalls = new Set>([...chained, ...chainedConnect]); + + // Priority on identified stream variable names within their scope. + for (const stream of streamVars) { + const calls = stream.scope.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + pattern: `${stream.name}.priority`, + }, + }, + }); + for (const c of calls) safeCalls.add(c); + } + + // Remove expression statements containing safe priority calls. + for (const call of safeCalls) { + let node: SgNode | undefined = call; + + while (node) { + const parent = node.parent(); + + if (parent?.kind() === 'expression_statement') { + linesToRemove.push(parent.range()); + break; + } + node = parent; + } + } + return { edits, linesToRemove }; } /** @@ -322,7 +327,7 @@ function removeSettingsPriority( const edits: Edit[] = []; // Chained connect().settings(...) const chained = rootNode.findAll({ - rule: { pattern: "$HTTP2.connect($$$_ARGS).settings($$$_ARGS)" }, + rule: { pattern: '$HTTP2.connect($$$_ARGS).settings($$$_ARGS)' }, }); const safeCalls = new Set>([...chained]); @@ -331,10 +336,10 @@ function removeSettingsPriority( for (const sess of sessionVars) { const calls = sess.scope.findAll({ rule: { - kind: "call_expression", + kind: 'call_expression', has: { - field: "function", - kind: "member_expression", + field: 'function', + kind: 'member_expression', pattern: `${sess.name}.settings`, }, }, @@ -343,22 +348,28 @@ function removeSettingsPriority( } for (const call of safeCalls) { - const objects = call.findAll({ rule: { kind: "object" } }); + const objects = call.findAll({ rule: { kind: 'object' } }); + for (const obj of objects) { - const pairs = obj.children().filter((child) => child.kind() === "pair"); + const pairs = obj.children().filter((child) => child.kind() === 'pair'); + let hasPriority = false; let allPriority = true; + for (const pair of pairs) { - const keyNode = pair.find({ rule: { kind: "property_identifier", regex: "^priority$" } }); - if (keyNode) hasPriority = true; else allPriority = false; + const keyNode = pair.find({ + rule: { kind: 'property_identifier', regex: '^priority$' }, + }); + if (keyNode) hasPriority = true; + else allPriority = false; } if (allPriority && hasPriority) { const callText = call.text(); const objText = obj.text(); let cleanedCall = callText; - cleanedCall = cleanedCall.replace(objText, ""); - cleanedCall = cleanedCall.replace(/,\s*\)/, ")"); - cleanedCall = cleanedCall.replace(/\(\s*,/, "("); + cleanedCall = cleanedCall.replace(objText, ''); + cleanedCall = cleanedCall.replace(/,\s*\)/, ')'); + cleanedCall = cleanedCall.replace(/\(\s*,/, '('); const finalCall = cleanedCall; if (finalCall !== callText) edits.push(call.replace(finalCall)); } else if (hasPriority) { @@ -379,43 +390,45 @@ function collectStreamVars( for (const sess of sessionVars) { const decls = sess.scope.findAll({ rule: { - kind: "variable_declarator", + kind: 'variable_declarator', has: { - field: "value", - kind: "call_expression", + field: 'value', + kind: 'call_expression', has: { - field: "function", - kind: "member_expression", + field: 'function', + kind: 'member_expression', pattern: `${sess.name}.request`, }, }, }, }); for (const d of decls) { - const nameNode = (d as SgNode).field("name"); + const nameNode = (d as SgNode).field('name'); streamVars.push({ name: nameNode.text(), scope: getLexicalScope(d) }); } } // From connect().request(...) chained assignments. const chainedDecls = rootNode.findAll({ rule: { - kind: "variable_declarator", + kind: 'variable_declarator', has: { - field: "value", - kind: "call_expression", + field: 'value', + kind: 'call_expression', has: { - field: "function", - kind: "member_expression", - pattern: "request", // we will validate parent chain text + field: 'function', + kind: 'member_expression', + pattern: 'request', // we will validate parent chain text }, }, }, }); for (const d of chainedDecls) { - const valueText = (d as SgNode).field("value").text(); + const valueText = (d as SgNode) + .field('value') + .text(); // Quick heuristic: contains ".connect(" before ".request(". if (/connect\s*\([^)]*\).*\.request\s*\(/.test(valueText)) { - const nameNode = (d as SgNode).field("name"); + const nameNode = (d as SgNode).field('name'); streamVars.push({ name: nameNode.text(), scope: getLexicalScope(d) }); } } @@ -430,7 +443,7 @@ function removePriorityPairFromObject(obj: SgNode): Edit[] { const pairs = obj.findAll({ rule: { - kind: "pair", + kind: 'pair', }, }); @@ -439,8 +452,8 @@ function removePriorityPairFromObject(obj: SgNode): Edit[] { for (const pair of pairs) { const keyNode = pair.find({ rule: { - kind: "property_identifier", - regex: "^priority$", + kind: 'property_identifier', + regex: '^priority$', }, }); @@ -455,7 +468,7 @@ function removePriorityPairFromObject(obj: SgNode): Edit[] { // If all pairs are priority, remove the entire object if (priorityPairs.length === pairs.length) { - edits.push(obj.replace("")); + edits.push(obj.replace('')); return edits; } @@ -471,28 +484,28 @@ function removePriorityPairFromObject(obj: SgNode): Edit[] { // Try to match and remove: ", priority: {...}" or similar // First try with leading comma const leadingCommaPattern = `,\\s*${escapeRegex(pairText)}`; - if (result.includes(",") && result.includes(pairText)) { - result = result.replace(new RegExp(leadingCommaPattern), ""); + if (result.includes(',') && result.includes(pairText)) { + result = result.replace(new RegExp(leadingCommaPattern), ''); } // If still not removed, try with trailing comma if (result.includes(pairText)) { const trailingCommaPattern = `${escapeRegex(pairText)},`; - result = result.replace(new RegExp(trailingCommaPattern), ""); + result = result.replace(new RegExp(trailingCommaPattern), ''); } // If still not removed, just remove the pair if (result.includes(pairText)) { - result = result.replace(pairText, ""); + result = result.replace(pairText, ''); } } // Clean up any resulting spacing issues - result = result.replace(/,\s*,/g, ","); - result = result.replace(/{\s*,/g, "{"); - result = result.replace(/,\s*}/g, "}"); - result = result.replace(/{(\S)/g, "{ $1"); - result = result.replace(/(\S)}/g, "$1 }"); + result = result.replace(/,\s*,/g, ','); + result = result.replace(/{\s*,/g, '{'); + result = result.replace(/,\s*}/g, '}'); + result = result.replace(/{(\S)/g, '{ $1'); + result = result.replace(/(\S)}/g, '$1 }'); if (result !== objText) { edits.push(obj.replace(result)); @@ -502,5 +515,5 @@ function removePriorityPairFromObject(obj: SgNode): Edit[] { } function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } diff --git a/utils/src/ast-grep/resolve-binding-path.ts b/utils/src/ast-grep/resolve-binding-path.ts index 42a06391..8363a538 100644 --- a/utils/src/ast-grep/resolve-binding-path.ts +++ b/utils/src/ast-grep/resolve-binding-path.ts @@ -42,7 +42,7 @@ export function resolveBindingPath(node: SgNode, path: string) { if (!supportedKinds.includes(rootKind.toString())) { throw Error( - `Invalid node kind. To resolve binding path, one of these types must be provided: ${supportedKinds.join(', ')}`, + `Invalid node kind. To resolve binding path, one of these types must be provided: ${supportedKinds.join(', ')}\n received: ${rootKind}`, ); }