diff --git a/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.test.ts b/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.test.ts index 349d89df..a6314ff5 100644 --- a/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.test.ts +++ b/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.test.ts @@ -57,6 +57,43 @@ describe("transformCtxCompat", () => { expect(r.content).toContain("ctx?: AppContext"); }); + // The scanner walks raw characters, so without a literal/comment guard it + // rewrote `ctx.` inside strings too — patching the call site correctly while + // silently corrupting the message next to it. + it("does not rewrite ctx inside string literals", () => { + const r = transformCtxCompat(withLoader(" console.log('ctx.device:', ctx.device);")); + expect(r.content).toContain("'ctx.device:'"); + expect(r.content).toContain("console.log('ctx.device:', ctx?.device)"); + }); + + it("does not rewrite ctx inside double-quoted strings or escaped quotes", () => { + const r = transformCtxCompat( + withLoader(' const s = "he said \\"ctx.device\\""; return ctx.device;'), + ); + expect(r.content).toContain('"he said \\"ctx.device\\""'); + expect(r.content).toContain("return ctx?.device"); + }); + + it("does not rewrite ctx inside comments", () => { + const r = transformCtxCompat( + withLoader(" // don't touch ctx.device here\n /* nor ctx.invoke */\n return ctx.device;"), + ); + expect(r.content).toContain("// don't touch ctx.device here"); + expect(r.content).toContain("/* nor ctx.invoke */"); + expect(r.content).toContain("return ctx?.device"); + }); + + it("does not rewrite template text but DOES rewrite ${} interpolation", () => { + const r = transformCtxCompat(withLoader(" const u = `ctx.device is ${ctx.vtex.account}`;")); + // literal text stays, the interpolated expression is real code + expect(r.content).toContain("`ctx.device is ${ctx?.vtex?.account}`"); + }); + + it("handles nested template literals", () => { + const r = transformCtxCompat(withLoader(" const u = `a${`b${ctx.device}`}c`;")); + expect(r.content).toContain("`a${`b${ctx?.device}`}c`"); + }); + it("does not match identifiers that merely contain ctx", () => { const src = withLoader(" const c = canvasCtx.foo;\n const d = a.ctx.bar;"); const r = transformCtxCompat(src); diff --git a/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.ts b/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.ts index 31c32e7f..54c1804a 100644 --- a/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.ts +++ b/packages/blocks-cli/scripts/migrate/transforms/ctx-compat.ts @@ -112,7 +112,99 @@ export function transformCtxCompat(content: string): TransformResult { let i = 0; let count = 0; + // Contexts we are inside of, innermost last. Without this the scanner walks + // raw characters and happily rewrites `ctx.` inside string literals and + // comments: `console.log('ctx.device:', ctx.device)` became + // `console.log('ctx?.device:', ctx?.device)` — the call site correctly + // patched, the message silently corrupted. + // + // A template literal pushes "template" (its text is data), and a `${` inside + // one pushes "expr" (real code, where rewriting must resume) — so + // `` `${ctx.device}` `` is still rewritten while `` `ctx.device` `` is not. + // + // Regex literals are deliberately NOT tracked: telling `/re/` from division + // needs real parsing, and guessing wrong would consume live code. A `ctx.` + // inside a regex in a loader is vanishingly rare next to that risk. + const stack: ("template" | "expr")[] = []; + while (i < content.length) { + const inTemplateText = stack[stack.length - 1] === "template"; + + if (inTemplateText) { + if (content[i] === "\\") { + out += content.slice(i, i + 2); + i += 2; + continue; + } + if (content[i] === "`") { + stack.pop(); + out += content[i]; + i += 1; + continue; + } + if (content.startsWith("${", i)) { + stack.push("expr"); + out += "${"; + i += 2; + continue; + } + out += content[i]; + i += 1; + continue; + } + + // Line comment — copy to end of line. + if (content.startsWith("//", i)) { + const nl = content.indexOf("\n", i); + const end = nl === -1 ? content.length : nl; + out += content.slice(i, end); + i = end; + continue; + } + + // Block comment — copy to the closing delimiter. + if (content.startsWith("/*", i)) { + const close = content.indexOf("*/", i + 2); + const end = close === -1 ? content.length : close + 2; + out += content.slice(i, end); + i = end; + continue; + } + + // Single/double quoted string — copy to the unescaped closing quote. + if (content[i] === "'" || content[i] === '"') { + const quote = content[i]; + let j = i + 1; + while (j < content.length) { + if (content[j] === "\\") { + j += 2; + continue; + } + if (content[j] === quote || content[j] === "\n") { + j += 1; + break; + } + j += 1; + } + out += content.slice(i, j); + i = j; + continue; + } + + if (content[i] === "`") { + stack.push("template"); + out += content[i]; + i += 1; + continue; + } + + if (content[i] === "}" && stack[stack.length - 1] === "expr") { + stack.pop(); + out += content[i]; + i += 1; + continue; + } + const isCtxToken = content.startsWith("ctx", i) && !isIdentChar(content[i - 1]) &&