| 1 | import type { |
| 2 | CallExpression, |
| 3 | ExportDefaultDeclaration, |
| 4 | ExportNamedDeclaration, |
| 5 | Expression, |
| 6 | ExpressionStatement, |
| 7 | NewExpression, |
| 8 | Program, |
| 9 | Statement, |
| 10 | VariableDeclaration, |
| 11 | } from "@oxc-project/types"; |
| 12 | import * as fs from "node:fs/promises"; |
| 13 | import * as path from "node:path"; |
| 14 | import picomatch from "picomatch"; |
| 15 | import type * as rolldown from "rolldown"; |
| 16 | |
| 17 | /** |
| 18 | * `rolldown` and `rolldown/parseAst` load `@rolldown/binding-*` at module |
| 19 | * scope, so they are imported lazily on first use — importing this module |
| 20 | * (e.g. via the `alchemy/Bundle` barrel) must never load the native |
| 21 | * binding (#562). |
| 22 | */ |
| 23 | const loadRolldown = () => import("rolldown"); |
| 24 | const loadParseAst = () => import("rolldown/parseAst"); |
| 25 | |
| 26 | /** |
| 27 | * Default packages whose modules will receive `/*#__PURE__*\/` annotations. |
| 28 | * Mirrors what `effect-smol` ships via `babel-plugin-annotate-pure-calls` |
| 29 | * applied to its own `dist/` output. |
| 30 | * |
| 31 | * `alchemy` is included so its own resources (which consist almost entirely |
| 32 | * of `Effect.fn(...)`, `Context.Service(...)(...)`, `Layer.effect(...)` and |
| 33 | * `Data.TaggedError(...)` top-level calls) become tree-shakeable. The |
| 34 | * package already declares `"sideEffects": false`, so it is safe. |
| 35 | * |
| 36 | * `@distilled.cloud/*` is included so the generated SDK service modules |
| 37 | * (large files of top-level `const op = make(...)` operation definitions) |
| 38 | * tree-shake down to the operations a Worker actually calls. Every |
| 39 | * distilled package declares `"sideEffects": false`. |
| 40 | */ |
| 41 | export const DEFAULT_PURE_PACKAGES: ReadonlyArray<string> = [ |
| 42 | "effect", |
| 43 | "@effect/*", |
| 44 | "alchemy", |
| 45 | "@alchemy.run/*", |
| 46 | "@distilled.cloud/*", |
| 47 | ]; |
| 48 | |
| 49 | /** |
| 50 | * Options for {@link purePlugin}. |
| 51 | */ |
| 52 | export interface PurePluginOptions { |
| 53 | /** |
| 54 | * Extra package names or globs to annotate, in addition to |
| 55 | * {@link DEFAULT_PURE_PACKAGES}. Globs are matched with picomatch |
| 56 | * against the package name (e.g. `effect`, `@effect/cluster`). |
| 57 | * |
| 58 | * Listing a package that declares `"sideEffects": false` (or `[]`) |
| 59 | * in its `package.json` opts it into full annotation, including |
| 60 | * top-level calls whose result is discarded — see {@link purePlugin}. |
| 61 | */ |
| 62 | readonly packages?: ReadonlyArray<string>; |
| 63 | /** |
| 64 | * If true, the configured `packages` list replaces the defaults |
| 65 | * entirely instead of extending them. |
| 66 | * @default false |
| 67 | */ |
| 68 | readonly replaceDefaults?: boolean; |
| 69 | /** |
| 70 | * If true, also marks matched modules as side-effect free |
| 71 | * (mirrors `"sideEffects": []` in package.json), so rolldown drops |
| 72 | * unused re-exports from those packages. |
| 73 | * @default true |
| 74 | */ |
| 75 | readonly markSideEffectFree?: boolean; |
| 76 | } |
| 77 | |
| 78 | const PURE_COMMENT = "/*#__PURE__*/ "; |
| 79 | const SUPPORTED_FILE_RE = /\.(?:m?[jt]sx?|cjs|cts)$/; |
| 80 | |
| 81 | /** |
| 82 | * Rolldown plugin that injects `/*#__PURE__*\/` annotations on top-level |
| 83 | * call/new expressions of modules belonging to the configured packages, |
| 84 | * enabling tree-shaking of `effect`, `@effect/*`, and any user-listed |
| 85 | * packages without requiring a babel post-build pass. |
| 86 | * |
| 87 | * Annotation is strictly explicit: only {@link DEFAULT_PURE_PACKAGES} and |
| 88 | * packages listed via `packages` are touched. Listing a package that |
| 89 | * declares `sideEffects: false` / `[]` is a deliberate opt-in to full |
| 90 | * annotation, including discarded-result statement calls (#949). The |
| 91 | * package owning the bundle entry gets no special treatment — apps that |
| 92 | * want their own bound calls tree-shaken list themselves explicitly. |
| 93 | */ |
| 94 | export const purePlugin = ( |
| 95 | options: PurePluginOptions = {}, |
| 96 | ): rolldown.Plugin => { |
| 97 | const patterns = options.replaceDefaults |
| 98 | ? [...(options.packages ?? DEFAULT_PURE_PACKAGES)] |
| 99 | : [...DEFAULT_PURE_PACKAGES, ...(options.packages ?? [])]; |
| 100 | const markSideEffectFreeOpt = options.markSideEffectFree ?? true; |
| 101 | const isMatch = picomatch(patterns); |
| 102 | // Per-bundle cache of directory -> owning package metadata. |
| 103 | // Avoids walking the filesystem for every module of a large package. |
| 104 | const pkgInfoCache = new Map<string, PackageInfo | null>(); |
| 105 | // Resolved absolute paths of entry modules. Marking an entry module as |
| 106 | // `moduleSideEffects: false` makes rolldown treat its top-level |
| 107 | // statements (including `console.log` etc.) as eliminable, often |
| 108 | // collapsing the whole bundle. We always preserve entries' side |
| 109 | // effects, regardless of the package they belong to. |
| 110 | const entryPaths = new Set<string>(); |
| 111 | |
| 112 | return { |
| 113 | name: "alchemy:annotate-pure", |
| 114 | options(opts) { |
| 115 | for (const input of inputFilePaths(opts)) entryPaths.add(input); |
| 116 | return null; |
| 117 | }, |
| 118 | transform: { |
| 119 | filter: { id: SUPPORTED_FILE_RE }, |
| 120 | async handler(code, id, meta) { |
| 121 | const cleanId = stripIdSuffix(id); |
| 122 | const info = await resolvePackageInfo( |
| 123 | path.dirname(cleanId), |
| 124 | pkgInfoCache, |
| 125 | ); |
| 126 | // A nameless package.json (e.g. a nested `dist/package.json` |
| 127 | // holding only `{"type": "module"}`) can't be matched against the |
| 128 | // configured patterns — fall back to the path-derived |
| 129 | // `node_modules/<pkg>` name when one exists. |
| 130 | const name = info?.name ?? packageNameFromId(cleanId); |
| 131 | if (name === null || !isMatch(name)) return null; |
| 132 | |
| 133 | // Only override `moduleSideEffects` when the owning package |
| 134 | // explicitly opts in via `sideEffects: false` / `[]`. Marking |
| 135 | // arbitrary modules side-effect-free could erase intentional |
| 136 | // registrations / mutations the author made at the top level of |
| 137 | // files in packages that did not declare so. |
| 138 | const isEntry = entryPaths.has(cleanId); |
| 139 | const sideEffectFreePkg = isSideEffectFree(info?.sideEffects); |
| 140 | const markSideEffectFree = |
| 141 | markSideEffectFreeOpt && !isEntry && sideEffectFreePkg; |
| 142 | |
| 143 | const anchors = await collectPureAnchorsCached(code, cleanId); |
| 144 | // Annotating a call whose result is BOUND (variable initializer, |
| 145 | // export) only lets the minifier drop it when the binding itself |
| 146 | // is unused — safe everywhere. Annotating a call whose result is |
| 147 | // DISCARDED (a bare expression statement like `app.get("/", h)`) |
| 148 | // is an instruction to delete the call under minification, so it |
| 149 | // is only applied when the owning package explicitly declared |
| 150 | // itself side-effect free, and never to entry modules, whose side |
| 151 | // effects we always preserve (#949). |
| 152 | const annotateDiscarded = !isEntry && sideEffectFreePkg; |
| 153 | const positions = |
| 154 | anchors === null |
| 155 | ? [] |
| 156 | : annotateDiscarded |
| 157 | ? [...anchors.bound, ...anchors.discarded] |
| 158 | : anchors.bound; |
| 159 | if (positions.length === 0) { |
| 160 | // Metadata-only result: omitting `code` tells rolldown the |
| 161 | // source was NOT transformed, so no sourcemap is expected and |
| 162 | // no SOURCEMAP_BROKEN warning is emitted. |
| 163 | return markSideEffectFree ? { moduleSideEffects: false } : null; |
| 164 | } |
| 165 | // `meta.magicString` is rolldown's native (Rust) MagicString over |
| 166 | // `code`. Returning it as `code` hands sourcemap generation to |
| 167 | // rolldown's native side (computed on a background thread). The |
| 168 | // fallback covers direct hook invocations (unit tests). |
| 169 | const s = |
| 170 | meta.magicString ?? |
| 171 | new (await loadRolldown()).RolldownMagicString(code); |
| 172 | for (const anchor of positions) s.appendLeft(anchor, PURE_COMMENT); |
| 173 | return { |
| 174 | code: s, |
| 175 | moduleSideEffects: markSideEffectFree ? false : null, |
| 176 | }; |
| 177 | }, |
| 178 | }, |
| 179 | }; |
| 180 | }; |
| 181 | |
| 182 | /** Strips rolldown's `?query` / `#hash` suffixes from a module id. */ |
| 183 | const stripIdSuffix = (id: string): string => id.replace(/[?#].*$/, ""); |
| 184 | |
| 185 | /** |
| 186 | * Returns the absolute paths of every real entry module declared on the |
| 187 | * rolldown input options. Handles all three input shapes (string, array, |
| 188 | * `{ name -> path }` record) and skips `\0`-prefixed virtual ids (e.g. |
| 189 | * the ones `virtualEntryPlugin` substitutes in its `pre` options hook). |
| 190 | */ |
| 191 | function inputFilePaths(opts: rolldown.InputOptions): string[] { |
| 192 | const raw: unknown[] = |
| 193 | typeof opts.input === "string" |
| 194 | ? [opts.input] |
| 195 | : Array.isArray(opts.input) |
| 196 | ? opts.input |
| 197 | : opts.input && typeof opts.input === "object" |
| 198 | ? Object.values(opts.input) |
| 199 | : []; |
| 200 | const cwd = opts.cwd ?? process.cwd(); |
| 201 | return raw |
| 202 | .filter( |
| 203 | (entry): entry is string => |
| 204 | typeof entry === "string" && !entry.startsWith("\0"), |
| 205 | ) |
| 206 | .map((entry) => path.resolve(cwd, entry)); |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Metadata extracted from a `package.json`. `name` may be `null` when the |
| 211 | * file exists but has no `"name"` field (rare, but possible for private |
| 212 | * subpackage roots). |
| 213 | */ |
| 214 | export interface PackageInfo { |
| 215 | readonly name: string | null; |
| 216 | readonly sideEffects: unknown; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * Resolves the owning {@link PackageInfo} for a directory by walking up |
| 221 | * to the nearest `package.json`. This is what makes the plugin work for |
| 222 | * workspace-linked sources (e.g. our own `packages/alchemy/src/**` when |
| 223 | * consumers import via the `worker`/`bun` conditions which resolve to |
| 224 | * `.ts`). |
| 225 | * |
| 226 | * Caches both positive and negative results per directory. Every visited |
| 227 | * directory is a descendant-or-self of the directory where the walk |
| 228 | * stops (found `package.json`, cache hit, `node_modules` boundary, or |
| 229 | * filesystem root), so backfilling all of them with the result never |
| 230 | * poisons sibling packages. |
| 231 | */ |
| 232 | export async function resolvePackageInfo( |
| 233 | startDir: string, |
| 234 | cache: Map<string, PackageInfo | null>, |
| 235 | ): Promise<PackageInfo | null> { |
| 236 | let dir = path.resolve(startDir); |
| 237 | const visited: string[] = []; |
| 238 | let result: PackageInfo | null = null; |
| 239 | // Hard ceiling to prevent runaway walks on weird paths. |
| 240 | for (let i = 0; i < 64; i++) { |
| 241 | // Never walk above a `node_modules` boundary: the owning package of a |
| 242 | // `node_modules/<pkg>/...` id lives at or below `<pkg>`. Climbing past |
| 243 | // it can latch onto an unrelated package.json higher up (e.g. a stray |
| 244 | // one at the filesystem root). |
| 245 | if (path.basename(dir) === "node_modules") break; |
| 246 | const cached = cache.get(dir); |
| 247 | if (cached !== undefined) { |
| 248 | result = cached; |
| 249 | break; |
| 250 | } |
| 251 | visited.push(dir); |
| 252 | const info = await readPackageJson(path.join(dir, "package.json")); |
| 253 | if (info !== null) { |
| 254 | result = info; |
| 255 | break; |
| 256 | } |
| 257 | const parent = path.dirname(dir); |
| 258 | if (parent === dir) break; |
| 259 | dir = parent; |
| 260 | } |
| 261 | for (const v of visited) cache.set(v, result); |
| 262 | return result; |
| 263 | } |
| 264 | |
| 265 | async function readPackageJson(file: string): Promise<PackageInfo | null> { |
| 266 | try { |
| 267 | const contents = await fs.readFile(file, "utf8"); |
| 268 | const json = JSON.parse(contents) as { |
| 269 | name?: unknown; |
| 270 | sideEffects?: unknown; |
| 271 | }; |
| 272 | return { |
| 273 | name: typeof json.name === "string" ? json.name : null, |
| 274 | sideEffects: json.sideEffects, |
| 275 | }; |
| 276 | } catch { |
| 277 | // package.json missing or unreadable. |
| 278 | return null; |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Mirrors how rolldown / rollup interpret the `sideEffects` package.json |
| 284 | * field. We only treat `false` and `[]` as "fully side-effect free"; an |
| 285 | * array of files or `true` is treated as not safe to auto-annotate the |
| 286 | * whole package. |
| 287 | */ |
| 288 | function isSideEffectFree(value: unknown): boolean { |
| 289 | if (value === false) return true; |
| 290 | if (Array.isArray(value) && value.length === 0) return true; |
| 291 | return false; |
| 292 | } |
| 293 | |
| 294 | /** |
| 295 | * Extracts the npm package name from a resolved module id by walking back to |
| 296 | * the last `node_modules/` segment. Handles scoped packages. This is the |
| 297 | * fast path that does not hit the filesystem; it works for ordinary |
| 298 | * `node_modules/<pkg>/...` ids but NOT for workspace-linked sources whose |
| 299 | * resolved path is e.g. `<repo>/packages/alchemy/src/Bundle/PurePlugin.ts`. |
| 300 | * |
| 301 | * @example |
| 302 | * packageNameFromId("/proj/node_modules/effect/dist/Effect.js") |
| 303 | * // => "effect" |
| 304 | * packageNameFromId("/proj/node_modules/@effect/cluster/dist/index.js") |
| 305 | * // => "@effect/cluster" |
| 306 | */ |
| 307 | export function packageNameFromId(id: string): string | null { |
| 308 | const normalized = id.replace(/\\/g, "/"); |
| 309 | const idx = normalized.lastIndexOf("/node_modules/"); |
| 310 | if (idx === -1) return null; |
| 311 | const rest = normalized.slice(idx + "/node_modules/".length); |
| 312 | const parts = rest.split("/"); |
| 313 | if (parts.length === 0 || parts[0] === "") return null; |
| 314 | if (parts[0].startsWith("@")) { |
| 315 | if (parts.length < 2) return null; |
| 316 | return `${parts[0]}/${parts[1]}`; |
| 317 | } |
| 318 | return parts[0]; |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Process-wide memo of {@link collectPureAnchors} results keyed by module id. |
| 323 | * |
| 324 | * Parsing with oxc dominates the plugin's main-thread CPU (it showed up as |
| 325 | * ~60% of the profile of a test run), and the same modules — effect/alchemy |
| 326 | * dist files — are re-scanned for EVERY bundle built in the process. The |
| 327 | * single-process test runner builds dozens of worker bundles per run, so |
| 328 | * this cache eliminates all repeat parses. The stored `code` is compared on |
| 329 | * lookup, so a changed file (e.g. dev watch mode) never serves a stale |
| 330 | * result. |
| 331 | */ |
| 332 | const anchorsCache = new Map< |
| 333 | string, |
| 334 | { readonly code: string; readonly anchors: PureAnchors | null } |
| 335 | >(); |
| 336 | const ANCHORS_CACHE_MAX = 10_000; |
| 337 | |
| 338 | const collectPureAnchorsCached = async ( |
| 339 | code: string, |
| 340 | filename: string, |
| 341 | ): Promise<PureAnchors | null> => { |
| 342 | const cached = anchorsCache.get(filename); |
| 343 | if (cached !== undefined && cached.code === code) { |
| 344 | return cached.anchors; |
| 345 | } |
| 346 | const anchors = await collectPureAnchors(code, filename); |
| 347 | if (anchorsCache.size >= ANCHORS_CACHE_MAX) { |
| 348 | anchorsCache.clear(); |
| 349 | } |
| 350 | anchorsCache.set(filename, { code, anchors }); |
| 351 | return anchors; |
| 352 | }; |
| 353 | |
| 354 | /** |
| 355 | * Offsets at which `/*#__PURE__*\/` may be inserted, split by whether the |
| 356 | * call's result is consumed. |
| 357 | */ |
| 358 | export interface PureAnchors { |
| 359 | /** |
| 360 | * Calls whose result is bound — variable initializers, exports, |
| 361 | * assignment right-hand sides. Annotating these only lets the minifier |
| 362 | * drop the call when the binding itself is unused, so it is safe for |
| 363 | * any module. |
| 364 | */ |
| 365 | readonly bound: number[]; |
| 366 | /** |
| 367 | * Top-level calls whose result is discarded (bare expression |
| 368 | * statements such as `app.get("/", handler)`). Annotating these tells |
| 369 | * the minifier the whole statement is deletable, so they are only safe |
| 370 | * in packages that explicitly declare `sideEffects: false` / `[]`. |
| 371 | */ |
| 372 | readonly discarded: number[]; |
| 373 | } |
| 374 | |
| 375 | /** |
| 376 | * Parses `code` and returns the offsets at which `/*#__PURE__*\/` must be |
| 377 | * inserted — before every top-level `CallExpression` callee / `new` |
| 378 | * keyword — classified by whether the call result is bound or discarded. |
| 379 | * Returns `null` if the file does not need to be modified (parse failure |
| 380 | * or no annotations needed). |
| 381 | */ |
| 382 | export async function collectPureAnchors( |
| 383 | code: string, |
| 384 | filename: string, |
| 385 | ): Promise<PureAnchors | null> { |
| 386 | const { parseAst } = await loadParseAst(); |
| 387 | let program: Program; |
| 388 | try { |
| 389 | // Use TS lang so the parser tolerates TS syntax (`as`, `satisfies`, |
| 390 | // non-null assertions) even when scanning published .js dist files |
| 391 | // — TS is a strict superset of JS for our purposes here. |
| 392 | program = parseAst(code, { sourceType: "module", lang: "ts" }, filename); |
| 393 | } catch { |
| 394 | return null; |
| 395 | } |
| 396 | |
| 397 | const bound: number[] = []; |
| 398 | const discarded: number[] = []; |
| 399 | |
| 400 | const visitCall = ( |
| 401 | call: CallExpression | NewExpression, |
| 402 | isDiscarded: boolean, |
| 403 | ) => { |
| 404 | if (isIIFE(call)) return; |
| 405 | // For `new X()`, anchor BEFORE the `new` keyword so we get |
| 406 | // `/*#__PURE__*/ new X()` (matches babel-plugin-annotate-pure-calls). |
| 407 | const anchor = |
| 408 | call.type === "NewExpression" ? call.start : call.callee.start; |
| 409 | if (alreadyAnnotated(code, anchor)) return; |
| 410 | (isDiscarded ? discarded : bound).push(anchor); |
| 411 | }; |
| 412 | |
| 413 | const visitExpression = ( |
| 414 | expr: Expression | null | undefined, |
| 415 | isDiscarded: boolean, |
| 416 | ) => { |
| 417 | if (!expr) return; |
| 418 | switch (expr.type) { |
| 419 | case "CallExpression": |
| 420 | case "NewExpression": |
| 421 | visitCall(expr, isDiscarded); |
| 422 | return; |
| 423 | case "SequenceExpression": { |
| 424 | // All but the last expression's results are always discarded; |
| 425 | // the last one takes the surrounding context. |
| 426 | const exprs = expr.expressions; |
| 427 | for (let i = 0; i < exprs.length; i++) { |
| 428 | visitExpression(exprs[i], i < exprs.length - 1 ? true : isDiscarded); |
| 429 | } |
| 430 | return; |
| 431 | } |
| 432 | case "ParenthesizedExpression": |
| 433 | visitExpression(expr.expression, isDiscarded); |
| 434 | return; |
| 435 | case "LogicalExpression": |
| 436 | visitExpression(expr.left, isDiscarded); |
| 437 | visitExpression(expr.right, isDiscarded); |
| 438 | return; |
| 439 | case "ConditionalExpression": |
| 440 | visitExpression(expr.consequent, isDiscarded); |
| 441 | visitExpression(expr.alternate, isDiscarded); |
| 442 | return; |
| 443 | case "AssignmentExpression": |
| 444 | // The right-hand side's result is stored in the target, so it is |
| 445 | // bound even when the assignment appears in statement position. |
| 446 | visitExpression(expr.right, false); |
| 447 | return; |
| 448 | case "TSAsExpression": |
| 449 | case "TSSatisfiesExpression": |
| 450 | case "TSNonNullExpression": |
| 451 | case "TSTypeAssertion": |
| 452 | visitExpression(expr.expression, isDiscarded); |
| 453 | return; |
| 454 | case "ChainExpression": { |
| 455 | const inner = expr.expression; |
| 456 | if (inner.type === "CallExpression") visitCall(inner, isDiscarded); |
| 457 | return; |
| 458 | } |
| 459 | default: |
| 460 | return; |
| 461 | } |
| 462 | }; |
| 463 | |
| 464 | const visitTopLevel = (node: Statement) => { |
| 465 | switch (node.type) { |
| 466 | case "ExpressionStatement": |
| 467 | visitExpression((node as ExpressionStatement).expression, true); |
| 468 | return; |
| 469 | case "VariableDeclaration": |
| 470 | for (const decl of (node as VariableDeclaration).declarations) { |
| 471 | visitExpression(decl.init, false); |
| 472 | } |
| 473 | return; |
| 474 | case "ExportNamedDeclaration": { |
| 475 | const decl = (node as ExportNamedDeclaration).declaration; |
| 476 | if (decl) visitTopLevel(decl as Statement); |
| 477 | return; |
| 478 | } |
| 479 | case "ExportDefaultDeclaration": { |
| 480 | const decl = (node as ExportDefaultDeclaration).declaration; |
| 481 | if ( |
| 482 | decl && |
| 483 | decl.type !== "FunctionDeclaration" && |
| 484 | decl.type !== "ClassDeclaration" && |
| 485 | decl.type !== "TSInterfaceDeclaration" |
| 486 | ) { |
| 487 | visitExpression(decl as Expression, false); |
| 488 | } |
| 489 | return; |
| 490 | } |
| 491 | default: |
| 492 | return; |
| 493 | } |
| 494 | }; |
| 495 | |
| 496 | for (const node of program.body) { |
| 497 | // Directive nodes (e.g. "use strict") have type "ExpressionStatement" |
| 498 | // with a `directive` field and a string literal expression — they |
| 499 | // cannot contain calls, so visiting them is a safe no-op. |
| 500 | visitTopLevel(node as Statement); |
| 501 | } |
| 502 | |
| 503 | return bound.length === 0 && discarded.length === 0 |
| 504 | ? null |
| 505 | : { bound, discarded }; |
| 506 | } |
| 507 | |
| 508 | function isIIFE(node: CallExpression | NewExpression): boolean { |
| 509 | let callee: Expression = node.callee; |
| 510 | while (callee.type === "ParenthesizedExpression") { |
| 511 | callee = callee.expression; |
| 512 | } |
| 513 | return ( |
| 514 | callee.type === "FunctionExpression" || |
| 515 | callee.type === "ArrowFunctionExpression" |
| 516 | ); |
| 517 | } |
| 518 | |
| 519 | function alreadyAnnotated(code: string, pos: number): boolean { |
| 520 | const start = Math.max(0, pos - 32); |
| 521 | const slice = code.slice(start, pos); |
| 522 | return slice.includes("/*#__PURE__*/") || slice.includes("/*@__PURE__*/"); |
| 523 | } |