|
| 1 | +// Shared machinery for deciding which published files belong in |
| 2 | +// package.json's `sideEffects` list. Used by the `updateSideEffects` plugin |
| 3 | +// in rollup.config.mjs (which writes the manifest during builds) and by |
| 4 | +// bin/why-side-effect.mjs (which explains why a file is flagged). |
| 5 | +import { createRequire } from 'node:module'; |
| 6 | +import { rollup } from 'rollup'; |
| 7 | + |
| 8 | +const require = createRequire(import.meta.url); |
| 9 | +const { transformAsync } = require('@babel/core'); |
| 10 | + |
| 11 | +/** |
| 12 | + * Rollup can't know that the classic object-model is lazy: |
| 13 | + * - `Mixin.create()` / `SomeClass.extend()` only build definitions, they don't |
| 14 | + * touch anything outside the values they return. |
| 15 | + * - `.reopen()` / `.reopenClass()` mutate their receiver, but when the |
| 16 | + * receiver is defined in the same file, dropping the whole file drops the |
| 17 | + * mutation along with it — so the file is still safe to omit when unused. |
| 18 | + * Reopening an *imported* value (e.g. ember-testing's RSVP/Application |
| 19 | + * extensions) is a real cross-module side-effect and stays flagged. |
| 20 | + * - `decorateMethodV2` / `decorateFieldV2` (decorator-transforms runtime, |
| 21 | + * emitted into class static blocks) only mutate the class being defined. |
| 22 | + * - descriptor factories (`computed`, `alias`, `service`, ...) only build |
| 23 | + * descriptor objects. |
| 24 | + * - registration helpers like `setClassicDecorator` or |
| 25 | + * `setInternalComponentManager` associate metadata with the values they are |
| 26 | + * given; when every argument is file-local, nobody can observe the |
| 27 | + * association unless they import this file's bindings. |
| 28 | + * - top-level variable declarations only initialize file-local bindings, so |
| 29 | + * every call/new inside their initializers is droppable along with the |
| 30 | + * binding (this covers e.g. module-level `new Cache(...)` instances, whose |
| 31 | + * constructor names are mangled in shared chunks and can't be matched by |
| 32 | + * name). |
| 33 | + * - top-level class declarations likewise only define a file-local class; |
| 34 | + * what runs at module evaluation (the `extends` expression, static blocks, |
| 35 | + * static field initializers) is droppable along with the class. |
| 36 | + * - a top-level expression statement whose effects all land on globals or |
| 37 | + * file-local values (e.g. `Object.setPrototypeOf(LocalClass.prototype, |
| 38 | + * Array.prototype)`, `LIBRARIES.registerCoreLibrary('Ember', VERSION)`) |
| 39 | + * can't be observed from outside the file, so it is droppable. Read-only |
| 40 | + * references to imports don't count as effects; what disqualifies a |
| 41 | + * statement is assigning into an imported value, calling an imported |
| 42 | + * function or a method on an imported receiver (e.g. RSVP wiring, |
| 43 | + * cross-chunk opcode registration), or passing an imported value as the |
| 44 | + * target of a known global mutator like `Object.assign`. |
| 45 | + * - the same goes for bare top-level blocks (the dev-build residue of |
| 46 | + * `if (DEBUG) { ... }`, e.g. `{ Object.seal(TargetActionSupport); }`), |
| 47 | + * as long as nothing in them has a cross-file effect or throws. |
| 48 | + * |
| 49 | + * This babel plugin marks those calls as `#__PURE__` (and deletes the |
| 50 | + * local-only statements, which annotations can't express) so the side-effect |
| 51 | + * probe below can tree-shake through them. It only runs inside the probe, |
| 52 | + * never on published output. |
| 53 | + */ |
| 54 | +export function annotatePureClassicCalls() { |
| 55 | + const alwaysPure = new Set(['create', 'extend']); |
| 56 | + const pureWhenLocal = new Set(['reopen', 'reopenClass']); |
| 57 | + const pureFunctions = new Set([ |
| 58 | + 'decorateMethodV2', |
| 59 | + 'decorateFieldV2', |
| 60 | + 'computed', |
| 61 | + 'alias', |
| 62 | + 'tracked', |
| 63 | + 'service', |
| 64 | + 'inject', |
| 65 | + 'dependentKeyCompat', |
| 66 | + // the @ember/object/computed macros all just build descriptor objects |
| 67 | + 'and', |
| 68 | + 'bool', |
| 69 | + 'collect', |
| 70 | + 'deprecatingAlias', |
| 71 | + 'empty', |
| 72 | + 'equal', |
| 73 | + 'filter', |
| 74 | + 'filterBy', |
| 75 | + 'gt', |
| 76 | + 'gte', |
| 77 | + 'intersect', |
| 78 | + 'lt', |
| 79 | + 'lte', |
| 80 | + 'map', |
| 81 | + 'mapBy', |
| 82 | + 'match', |
| 83 | + 'max', |
| 84 | + 'min', |
| 85 | + 'none', |
| 86 | + 'not', |
| 87 | + 'notEmpty', |
| 88 | + 'oneWay', |
| 89 | + 'or', |
| 90 | + 'readOnly', |
| 91 | + 'reads', |
| 92 | + 'setDiff', |
| 93 | + 'sort', |
| 94 | + 'sum', |
| 95 | + 'union', |
| 96 | + 'uniq', |
| 97 | + 'uniqBy', |
| 98 | + ]); |
| 99 | + // helpers that associate metadata keyed by one of their arguments (the |
| 100 | + // index in this map); when the key value is file-local, the association |
| 101 | + // can only ever be looked up through this file's bindings, so it is |
| 102 | + // unobservable unless the file is imported. The other arguments are merely |
| 103 | + // stored, which is no more observable than reading them. Rollup's output |
| 104 | + // preserves these local names even in shared chunks, so matching by name |
| 105 | + // is reliable. |
| 106 | + const pureWhenKeyArgIsLocal = new Map([ |
| 107 | + ['setClassicDecorator', 0], |
| 108 | + ['setHelperManager', 1], |
| 109 | + ['setInternalHelperManager', 1], |
| 110 | + ['setComponentTemplate', 1], |
| 111 | + ['setComponentManager', 1], |
| 112 | + ['setInternalComponentManager', 1], |
| 113 | + ['setModifierManager', 1], |
| 114 | + ['setInternalModifierManager', 1], |
| 115 | + ['internalHelper', 0], |
| 116 | + ['debugFreeze', 0], |
| 117 | + ['setProxy', 0], |
| 118 | + ['addListener', 0], |
| 119 | + ['removeListener', 0], |
| 120 | + ]); |
| 121 | + |
| 122 | + // functions that run their callback argument on the spot (used for |
| 123 | + // module-eval warm-ups); the callback body is what gets judged |
| 124 | + const invokesCallbackInline = new Set(['runInDebug', 'track']); |
| 125 | + |
| 126 | + function isImportBinding(binding) { |
| 127 | + return ( |
| 128 | + binding.path.isImportSpecifier() || |
| 129 | + binding.path.isImportDefaultSpecifier() || |
| 130 | + binding.path.isImportNamespaceSpecifier() |
| 131 | + ); |
| 132 | + } |
| 133 | + |
| 134 | + function receiverIsLocal(path, object) { |
| 135 | + // a call result (e.g. `EmberObject.extend({}).reopen({})`) is a value |
| 136 | + // created in this file |
| 137 | + if (object.type === 'CallExpression') return true; |
| 138 | + if (object.type !== 'Identifier') return false; |
| 139 | + let binding = path.scope.getBinding(object.name); |
| 140 | + return Boolean(binding) && !isImportBinding(binding); |
| 141 | + } |
| 142 | + |
| 143 | + // `Object`/`Reflect` helpers that mutate their first argument |
| 144 | + const globalMutators = new Set([ |
| 145 | + 'assign', |
| 146 | + 'defineProperty', |
| 147 | + 'defineProperties', |
| 148 | + 'setPrototypeOf', |
| 149 | + 'freeze', |
| 150 | + 'seal', |
| 151 | + 'set', |
| 152 | + 'deleteProperty', |
| 153 | + ]); |
| 154 | + |
| 155 | + // walk e.g. `a.b().c` down to `a` |
| 156 | + function baseIdentifier(node) { |
| 157 | + let current = node; |
| 158 | + for (;;) { |
| 159 | + if (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') { |
| 160 | + current = current.object; |
| 161 | + } else if ( |
| 162 | + current.type === 'CallExpression' || |
| 163 | + current.type === 'OptionalCallExpression' || |
| 164 | + current.type === 'NewExpression' |
| 165 | + ) { |
| 166 | + current = current.callee; |
| 167 | + } else { |
| 168 | + break; |
| 169 | + } |
| 170 | + } |
| 171 | + return current.type === 'Identifier' ? current : null; |
| 172 | + } |
| 173 | + |
| 174 | + function isImported(path, node) { |
| 175 | + let base = baseIdentifier(node); |
| 176 | + if (!base) return false; |
| 177 | + let binding = path.scope.getBinding(base.name); |
| 178 | + return Boolean(binding) && isImportBinding(binding); |
| 179 | + } |
| 180 | + |
| 181 | + function hasCrossFileEffect(statementPath) { |
| 182 | + let found = false; |
| 183 | + function fail(path) { |
| 184 | + found = true; |
| 185 | + path.stop(); |
| 186 | + } |
| 187 | + function checkCall(path) { |
| 188 | + let { callee, arguments: args } = path.node; |
| 189 | + // calls the rules above already declare pure can't be cross-file effects |
| 190 | + if (callee.type === 'Identifier' && pureFunctions.has(callee.name)) return; |
| 191 | + if (callee.type === 'Identifier' && pureWhenKeyArgIsLocal.has(callee.name)) { |
| 192 | + let key = args[pureWhenKeyArgIsLocal.get(callee.name)]; |
| 193 | + if (key && key.type !== 'SpreadElement' && !isImported(path, key)) return; |
| 194 | + } |
| 195 | + // these invoke their callback immediately and are otherwise inert |
| 196 | + // (track's frame push/pop is transient and its tag is discarded), so |
| 197 | + // the callback body — judged by the traversal below, see the Function |
| 198 | + // visitor — is the only thing that matters |
| 199 | + if (callee.type === 'Identifier' && invokesCallbackInline.has(callee.name)) return; |
| 200 | + if ( |
| 201 | + callee.type === 'MemberExpression' && |
| 202 | + !callee.computed && |
| 203 | + callee.property.type === 'Identifier' && |
| 204 | + alwaysPure.has(callee.property.name) |
| 205 | + ) { |
| 206 | + return; |
| 207 | + } |
| 208 | + if (isImported(path, callee)) return fail(path); |
| 209 | + // `Object.assign(imported, ...)` mutates its argument, not its receiver |
| 210 | + if ( |
| 211 | + callee.type === 'MemberExpression' && |
| 212 | + !callee.computed && |
| 213 | + callee.object.type === 'Identifier' && |
| 214 | + (callee.object.name === 'Object' || callee.object.name === 'Reflect') && |
| 215 | + !statementPath.scope.getBinding(callee.object.name) && |
| 216 | + callee.property.type === 'Identifier' && |
| 217 | + globalMutators.has(callee.property.name) && |
| 218 | + args[0] && |
| 219 | + isImported(path, args[0]) |
| 220 | + ) { |
| 221 | + return fail(path); |
| 222 | + } |
| 223 | + } |
| 224 | + statementPath.traverse({ |
| 225 | + Function(fnPath) { |
| 226 | + // a deferred function body only runs (if ever) after module |
| 227 | + // evaluation; an immediately-invoked one runs now and its body |
| 228 | + // counts, as do callbacks of inline invokers like runInDebug/track |
| 229 | + let parent = fnPath.parentPath; |
| 230 | + let isIife = parent.isCallExpression() && parent.node.callee === fnPath.node; |
| 231 | + let isInlineCallback = |
| 232 | + parent.isCallExpression() && |
| 233 | + parent.node.callee.type === 'Identifier' && |
| 234 | + invokesCallbackInline.has(parent.node.callee.name) && |
| 235 | + parent.node.arguments[0] === fnPath.node; |
| 236 | + if (!isIife && !isInlineCallback) fnPath.skip(); |
| 237 | + }, |
| 238 | + AssignmentExpression(path) { |
| 239 | + if (isImported(path, path.node.left)) fail(path); |
| 240 | + }, |
| 241 | + UpdateExpression(path) { |
| 242 | + if (isImported(path, path.node.argument)) fail(path); |
| 243 | + }, |
| 244 | + UnaryExpression(path) { |
| 245 | + if (path.node.operator === 'delete' && isImported(path, path.node.argument)) fail(path); |
| 246 | + }, |
| 247 | + CallExpression: checkCall, |
| 248 | + OptionalCallExpression: checkCall, |
| 249 | + NewExpression: checkCall, |
| 250 | + TaggedTemplateExpression(path) { |
| 251 | + if (isImported(path, path.node.tag)) fail(path); |
| 252 | + }, |
| 253 | + // a throw at module evaluation is observable even when nothing imports |
| 254 | + // the file's bindings |
| 255 | + ThrowStatement: fail, |
| 256 | + }); |
| 257 | + return found; |
| 258 | + } |
| 259 | + |
| 260 | + function annotate(path) { |
| 261 | + if (path.node.leadingComments?.some((c) => /[@#]__PURE__/.test(c.value))) return; |
| 262 | + path.addComment('leading', '#__PURE__'); |
| 263 | + } |
| 264 | + |
| 265 | + function isTopLevel(path) { |
| 266 | + let parent = path.parentPath; |
| 267 | + if (parent.isProgram()) return true; |
| 268 | + return ( |
| 269 | + (parent.isExportNamedDeclaration() || parent.isExportDefaultDeclaration()) && |
| 270 | + parent.parentPath.isProgram() |
| 271 | + ); |
| 272 | + } |
| 273 | + |
| 274 | + // calls inside nested function bodies run later (if ever), not at module |
| 275 | + // evaluation, so they must keep their real semantics |
| 276 | + const annotateEvalTimeCalls = { |
| 277 | + Function(fnPath) { |
| 278 | + fnPath.skip(); |
| 279 | + }, |
| 280 | + CallExpression: annotate, |
| 281 | + NewExpression: annotate, |
| 282 | + }; |
| 283 | + |
| 284 | + return { |
| 285 | + name: 'annotate-pure-classic-calls', |
| 286 | + visitor: { |
| 287 | + CallExpression(path) { |
| 288 | + let { callee } = path.node; |
| 289 | + if (callee.type === 'Identifier' && pureFunctions.has(callee.name)) { |
| 290 | + annotate(path); |
| 291 | + return; |
| 292 | + } |
| 293 | + if (callee.type !== 'MemberExpression' || callee.computed) return; |
| 294 | + if (callee.property.type !== 'Identifier') return; |
| 295 | + let method = callee.property.name; |
| 296 | + let isPure = |
| 297 | + alwaysPure.has(method) || |
| 298 | + (pureWhenLocal.has(method) && receiverIsLocal(path, callee.object)); |
| 299 | + if (isPure) annotate(path); |
| 300 | + }, |
| 301 | + VariableDeclaration(path) { |
| 302 | + if (!isTopLevel(path)) return; |
| 303 | + path.traverse(annotateEvalTimeCalls); |
| 304 | + }, |
| 305 | + ClassDeclaration(path) { |
| 306 | + if (!isTopLevel(path)) return; |
| 307 | + path.traverse(annotateEvalTimeCalls); |
| 308 | + }, |
| 309 | + ExpressionStatement(path) { |
| 310 | + if (!path.parentPath.isProgram()) return; |
| 311 | + if (hasCrossFileEffect(path)) return; |
| 312 | + path.remove(); |
| 313 | + }, |
| 314 | + BlockStatement(path) { |
| 315 | + if (!path.parentPath.isProgram()) return; |
| 316 | + if (hasCrossFileEffect(path)) return; |
| 317 | + path.remove(); |
| 318 | + }, |
| 319 | + }, |
| 320 | + }; |
| 321 | +} |
| 322 | + |
| 323 | +const entryId = '\0side-effect-probe-entry'; |
| 324 | + |
| 325 | +/** |
| 326 | + * Re-bundles a built file by itself (every import externalized) and returns |
| 327 | + * whatever code survives tree-shaking. An empty result means importing the |
| 328 | + * file does nothing observable, i.e. it is side-effect free. |
| 329 | + */ |
| 330 | +export async function probeSurvivingCode(file) { |
| 331 | + let bundle; |
| 332 | + try { |
| 333 | + bundle = await rollup({ |
| 334 | + input: entryId, |
| 335 | + treeshake: { |
| 336 | + moduleSideEffects: 'no-external', |
| 337 | + /** |
| 338 | + * The few property accesses that remain after the above |
| 339 | + * tree-shaking (e.g. reading Mixin.prototype.reopen) are not |
| 340 | + * effectful, so they shouldn't force a whole file into the |
| 341 | + * sideEffects list. |
| 342 | + */ |
| 343 | + propertyReadSideEffects: false, |
| 344 | + }, |
| 345 | + onwarn() {}, |
| 346 | + plugins: [ |
| 347 | + { |
| 348 | + name: 'side-effect-probe', |
| 349 | + resolveId(source, importer) { |
| 350 | + if (source === entryId) return entryId; |
| 351 | + if (importer === file) return { id: source, external: true }; |
| 352 | + return null; |
| 353 | + }, |
| 354 | + load(id) { |
| 355 | + if (id === entryId) return `import ${JSON.stringify(file)};`; |
| 356 | + }, |
| 357 | + async transform(code, id) { |
| 358 | + if (id !== file) return null; |
| 359 | + let result = await transformAsync(code, { |
| 360 | + configFile: false, |
| 361 | + babelrc: false, |
| 362 | + plugins: [annotatePureClassicCalls], |
| 363 | + }); |
| 364 | + return { code: result.code, map: null }; |
| 365 | + }, |
| 366 | + }, |
| 367 | + ], |
| 368 | + }); |
| 369 | + let { output } = await bundle.generate({ format: 'es' }); |
| 370 | + return output[0].code; |
| 371 | + } finally { |
| 372 | + if (bundle) await bundle.close(); |
| 373 | + } |
| 374 | +} |
| 375 | + |
| 376 | +export async function hasNoSideEffects(file) { |
| 377 | + let code = await probeSurvivingCode(file); |
| 378 | + return code.trim() === ''; |
| 379 | +} |
0 commit comments