Skip to content

Commit 4a06c42

Browse files
committed
Automatically annotate things we think are probably safe when generating the sideEffects list
More Automatically annotate things we think are probably safe when generating the sideEffects list Automatically annotate things we think are probably safe when generating the sideEffects list Automatically annotate things we think are probably safe when generating the sideEffects list Automatically annotate things we think are probably safe when generating the sideEffects list
1 parent f448415 commit 4a06c42

5 files changed

Lines changed: 430 additions & 171 deletions

File tree

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
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

Comments
 (0)