svelte-shaker 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{types/src/analyze.d.ts → analyze.d.ts} +3 -3
- package/dist/analyze.js +1072 -0
- package/dist/{types/src/css.d.ts → css.d.ts} +2 -2
- package/dist/css.js +256 -0
- package/dist/{types/src/dead.d.ts → dead.d.ts} +2 -2
- package/dist/dead.js +195 -0
- package/dist/{types/src/engine.d.ts → engine.d.ts} +3 -3
- package/dist/engine.js +109 -0
- package/dist/{types/src/eval.d.ts → eval.d.ts} +2 -2
- package/dist/eval.js +222 -0
- package/dist/{types/src/index.d.ts → index.d.ts} +11 -11
- package/dist/index.js +86 -1
- package/dist/ir.js +15 -0
- package/dist/{types/src/mono.d.ts → mono.d.ts} +3 -3
- package/dist/mono.js +451 -0
- package/dist/parse.js +19 -0
- package/dist/{types/src/rsvelte-parse.d.ts → rsvelte-parse.d.ts} +1 -1
- package/dist/rsvelte-parse.js +31 -0
- package/dist/{types/src/scan.d.ts → scan.d.ts} +2 -2
- package/dist/scan.js +40 -1
- package/dist/{types/src/transform.d.ts → transform.d.ts} +4 -4
- package/dist/transform.js +825 -0
- package/dist/{types/src/vite.d.ts → vite.d.ts} +10 -2
- package/dist/vite.js +289 -1
- package/package.json +9 -18
- package/dist/index.cjs +0 -1
- /package/dist/{types/src/ir.d.ts → ir.d.ts} +0 -0
- /package/dist/{types/src/parse.d.ts → parse.d.ts} +0 -0
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
import { parseCached, parseSvelte, walk, } from './parse.js';
|
|
2
|
+
import { emptyPlan, } from './ir.js';
|
|
3
|
+
import { computeDeadSpans, inSpans } from './dead.js';
|
|
4
|
+
const isSvelte = (source) => source.endsWith('.svelte');
|
|
5
|
+
/** Hard cap on fixpoint iterations: convergence is monotone (dead spans only
|
|
6
|
+
* grow as profiles shrink), so this is reached only if something is non-monotone
|
|
7
|
+
* — in which case we stop on the last stable plans rather than loop forever. */
|
|
8
|
+
const MAX_FIXPOINT_ITERATIONS = 10;
|
|
9
|
+
/** Bail reason stamped on a component leaked as a value (docs §4.1 escape). */
|
|
10
|
+
const ESCAPE_REASON = 'escapes as value (e.g. <svelte:component this={X}>)';
|
|
11
|
+
/**
|
|
12
|
+
* Crawl the component graph from `entries` and compute a plan per component,
|
|
13
|
+
* iterating to a whole-program fixpoint (docs §2.1).
|
|
14
|
+
*
|
|
15
|
+
* The crucial cascade: a `<Child/>` that lives inside a branch we fold away must
|
|
16
|
+
* NOT count toward the child's prop profile. Excluding it can shrink the
|
|
17
|
+
* child's value sets and enable more folding, which can fold away yet more
|
|
18
|
+
* branches. So we parse every component once, then alternate between
|
|
19
|
+
* (a) collecting call sites that are NOT inside a current dead span, and
|
|
20
|
+
* (b) recomputing plans (and hence dead spans) from that usage,
|
|
21
|
+
* until the plans stop changing.
|
|
22
|
+
*/
|
|
23
|
+
export async function analyze(entries, resolve, readFile) {
|
|
24
|
+
return analyzeInput(await buildAnalyzeInput(entries, resolve, readFile));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The pure, environment-free engine entry (docs/RUST-MIGRATION.md §2): given a
|
|
28
|
+
* fully-resolved, batched {@link AnalyzeInput}, build every component's model and
|
|
29
|
+
* compute its plan to a whole-program fixpoint (docs §2.1). It does NO module
|
|
30
|
+
* resolution or file IO — that is the Shell-side resolution layer's job
|
|
31
|
+
* ({@link buildAnalyzeInput}) — so this is the half that ports to Rust unchanged:
|
|
32
|
+
* one batched call in, plans out, no per-edge callback across the boundary.
|
|
33
|
+
*/
|
|
34
|
+
export function analyzeInput(input, parseCache) {
|
|
35
|
+
const models = buildModels(input, parseCache);
|
|
36
|
+
// Escape bail (docs §4.1): any component leaked as a value somewhere in the
|
|
37
|
+
// program (e.g. `<svelte:component this={X}>`) has an unobservable prop
|
|
38
|
+
// profile, so it must be left completely untouched. We union escapes across
|
|
39
|
+
// every file and stamp a bail reason on each escaped component's model BEFORE
|
|
40
|
+
// planning, so `buildPlan` bails it and the fixpoint never folds it.
|
|
41
|
+
const escaped = new Set();
|
|
42
|
+
for (const model of models.values())
|
|
43
|
+
for (const id of model.escapedComponents)
|
|
44
|
+
escaped.add(id);
|
|
45
|
+
for (const id of escaped) {
|
|
46
|
+
const model = models.get(id);
|
|
47
|
+
if (model && !model.bailReasons.includes(ESCAPE_REASON))
|
|
48
|
+
model.bailReasons.push(ESCAPE_REASON);
|
|
49
|
+
}
|
|
50
|
+
// Round 0: every call site counts (no dead spans yet) — the plain, non-cascade
|
|
51
|
+
// analysis. Each subsequent round recomputes dead spans from the previous
|
|
52
|
+
// plans and re-derives plans from the call sites that survive, layering the
|
|
53
|
+
// cascade on top, until the plans stop changing.
|
|
54
|
+
let plans = buildPlans(models, buildUsage(models, new Map()));
|
|
55
|
+
for (let i = 0; i < MAX_FIXPOINT_ITERATIONS; i++) {
|
|
56
|
+
const deadSpans = deadSpansForPlans(models, plans);
|
|
57
|
+
const nextPlans = buildPlans(models, buildUsage(models, deadSpans));
|
|
58
|
+
// Convergence is monotone: excluding a folded-away call site can only shrink
|
|
59
|
+
// a child's value set (or clear `dynamic`/`top`), never grow it, so dead
|
|
60
|
+
// spans only grow. Equal plans => a true fixpoint; we then stop.
|
|
61
|
+
if (plansEqual(plans, nextPlans)) {
|
|
62
|
+
plans = nextPlans;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
plans = nextPlans;
|
|
66
|
+
}
|
|
67
|
+
return { models, plans };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build a {@link FileModel} per `.svelte` file from the batched input — the
|
|
71
|
+
* resolution-free counterpart of the old crawl. Models are created in the
|
|
72
|
+
* input's file order (the Shell crawls breadth-first), so the output order is
|
|
73
|
+
* stable and matches the pre-batch behavior.
|
|
74
|
+
*/
|
|
75
|
+
function buildModels(input, parseCache) {
|
|
76
|
+
// Group resolved edges by their owning file so each model reads only its own.
|
|
77
|
+
const edgesByFrom = new Map();
|
|
78
|
+
for (const edge of input.edges) {
|
|
79
|
+
const list = edgesByFrom.get(edge.from);
|
|
80
|
+
if (list)
|
|
81
|
+
list.push(edge);
|
|
82
|
+
else
|
|
83
|
+
edgesByFrom.set(edge.from, [edge]);
|
|
84
|
+
}
|
|
85
|
+
const models = new Map();
|
|
86
|
+
for (const file of input.files) {
|
|
87
|
+
models.set(file.id, buildModelFromInput(file, edgesByFrom.get(file.id) ?? [], parseCache));
|
|
88
|
+
}
|
|
89
|
+
return models;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The Shell-side resolution + IO layer (docs/RUST-MIGRATION.md §2.1): BFS-crawl
|
|
93
|
+
* the component graph from `entries`, resolving every import edge and reading
|
|
94
|
+
* every reachable `.svelte` file up front, into a batched {@link AnalyzeInput}.
|
|
95
|
+
*
|
|
96
|
+
* This is the half that STAYS in JS — it owns `this.resolve` / file IO for Vite
|
|
97
|
+
* ecosystem compat (docs ARCHITECTURE §5/§9) — so the engine ({@link
|
|
98
|
+
* analyzeInput}) consumes its output with no callback across the boundary. The
|
|
99
|
+
* traversal mirrors the old crawl exactly: direct default-`.svelte` children and
|
|
100
|
+
* the barrel children a file actually RENDERS are followed (an unrendered barrel
|
|
101
|
+
* import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
|
|
102
|
+
* value set), keeping the produced model set — and thus the output — identical.
|
|
103
|
+
*/
|
|
104
|
+
export async function buildAnalyzeInput(entries, resolve, readFile, parseCache, parse) {
|
|
105
|
+
const entryList = Array.isArray(entries) ? [...entries] : [entries];
|
|
106
|
+
const files = [];
|
|
107
|
+
const edges = [];
|
|
108
|
+
const queue = [...entryList];
|
|
109
|
+
const seen = new Set(queue);
|
|
110
|
+
while (queue.length > 0) {
|
|
111
|
+
const id = queue.shift();
|
|
112
|
+
const code = await readFile(id);
|
|
113
|
+
files.push({ id, code });
|
|
114
|
+
const ast = parseCached(id, code, parseCache, parse);
|
|
115
|
+
const instance = ast.instance;
|
|
116
|
+
if (!instance)
|
|
117
|
+
continue;
|
|
118
|
+
// Resolve this file's imports into the three attributable edge kinds. Direct
|
|
119
|
+
// default `.svelte` and simple barrel/named imports bind a bare local; a
|
|
120
|
+
// namespace import (`import * as ns`) binds no single component, so it is
|
|
121
|
+
// deferred to its rendered `<ns.X>` member tags below.
|
|
122
|
+
const barrelLocals = new Map();
|
|
123
|
+
const namespaceSources = new Map();
|
|
124
|
+
const directChildren = [];
|
|
125
|
+
for (const imp of importSources(instance)) {
|
|
126
|
+
if (imp.imported === '*') {
|
|
127
|
+
namespaceSources.set(imp.local, imp.value);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (imp.imported === 'default' && isSvelte(imp.value)) {
|
|
131
|
+
const childId = await resolve(imp.value, id);
|
|
132
|
+
if (childId) {
|
|
133
|
+
edges.push({ from: id, local: imp.local, to: childId, kind: 'default-svelte' });
|
|
134
|
+
directChildren.push(childId);
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const childId = await resolveThroughBarrel(imp.value, imp.imported, id, resolve, readFile);
|
|
139
|
+
if (childId) {
|
|
140
|
+
edges.push({ from: id, local: imp.local, to: childId, kind: 'barrel' });
|
|
141
|
+
barrelLocals.set(imp.local, childId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Namespace member renders (`<ns.X .../>`): resolve each `X` through the SAME
|
|
145
|
+
// barrel logic a named `import { X } from '@ui'` uses, so a member tag is
|
|
146
|
+
// attributed exactly when (and to the same component as) the equivalent named
|
|
147
|
+
// import would be — its success/failure is correlated, which is what keeps
|
|
148
|
+
// mixing the two forms sound. The edge's `local` is the dotted tag the site
|
|
149
|
+
// renders, so the engine attributes `<ns.X .../>` by name lookup.
|
|
150
|
+
const nsChildren = [];
|
|
151
|
+
if (namespaceSources.size > 0) {
|
|
152
|
+
for (const tag of memberComponentTags(ast)) {
|
|
153
|
+
const dot = tag.indexOf('.');
|
|
154
|
+
const source = namespaceSources.get(tag.slice(0, dot));
|
|
155
|
+
if (source == null)
|
|
156
|
+
continue;
|
|
157
|
+
const childId = await resolveThroughBarrel(source, tag.slice(dot + 1), id, resolve, readFile);
|
|
158
|
+
if (childId) {
|
|
159
|
+
edges.push({ from: id, local: tag, to: childId, kind: 'namespace' });
|
|
160
|
+
nsChildren.push(childId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Enqueue every child this file renders: direct `.svelte`, the barrel children
|
|
165
|
+
// it renders, and the namespace members it renders.
|
|
166
|
+
const rendered = collectBarrelChildIds(ast, barrelLocals);
|
|
167
|
+
for (const childId of [...directChildren, ...rendered, ...nsChildren]) {
|
|
168
|
+
if (!seen.has(childId)) {
|
|
169
|
+
seen.add(childId);
|
|
170
|
+
queue.push(childId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { files, edges, entries: entryList };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Aggregate every component's call sites into per-child {@link Usage}, EXCLUDING
|
|
178
|
+
* any `<Child/>` whose node falls inside a dead `{#if}` span of its containing
|
|
179
|
+
* component. This is what makes the cascade sound: a folded-away call site does
|
|
180
|
+
* not contribute to the child's prop profile.
|
|
181
|
+
*/
|
|
182
|
+
function buildUsage(models, deadSpans) {
|
|
183
|
+
const usage = new Map();
|
|
184
|
+
const usageOf = (id) => {
|
|
185
|
+
let u = usage.get(id);
|
|
186
|
+
if (!u) {
|
|
187
|
+
u = { sites: [] };
|
|
188
|
+
usage.set(id, u);
|
|
189
|
+
}
|
|
190
|
+
return u;
|
|
191
|
+
};
|
|
192
|
+
for (const model of models.values()) {
|
|
193
|
+
const dead = deadSpans.get(model.id) ?? [];
|
|
194
|
+
for (const call of model.childCalls) {
|
|
195
|
+
// Soundness: only EXCLUDE a site that is provably inside a dead span (by
|
|
196
|
+
// the SAME predicate the transform uses). Live sites always count.
|
|
197
|
+
if (dead.length > 0 && inSpans(call.node, dead))
|
|
198
|
+
continue;
|
|
199
|
+
usageOf(call.childId).sites.push(readCallSite(call.node));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return usage;
|
|
203
|
+
}
|
|
204
|
+
/** Recompute every component's plan from the (cascade-filtered) usage. */
|
|
205
|
+
function buildPlans(models, usage) {
|
|
206
|
+
const plans = new Map();
|
|
207
|
+
for (const model of models.values()) {
|
|
208
|
+
plans.set(model.id, buildPlan(model, usage.get(model.id)));
|
|
209
|
+
}
|
|
210
|
+
return plans;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Fixpoint convergence test: the iteration is stable when every component's
|
|
214
|
+
* foldable decisions (`constFold` + `narrow`) are unchanged. Those two maps
|
|
215
|
+
* fully determine the dead spans (via {@link computeDeadSpans}) and the editing,
|
|
216
|
+
* so equal decisions => identical next round. `bail` is structural (it never
|
|
217
|
+
* changes across rounds) but is cheap to include for safety.
|
|
218
|
+
*/
|
|
219
|
+
function plansEqual(a, b) {
|
|
220
|
+
if (a.size !== b.size)
|
|
221
|
+
return false;
|
|
222
|
+
for (const [id, pa] of a) {
|
|
223
|
+
const pb = b.get(id);
|
|
224
|
+
if (!pb)
|
|
225
|
+
return false;
|
|
226
|
+
if (pa.bail !== pb.bail)
|
|
227
|
+
return false;
|
|
228
|
+
if (!literalMapEqual(pa.constFold, pb.constFold))
|
|
229
|
+
return false;
|
|
230
|
+
if (!literalArrayMapEqual(pa.narrow, pb.narrow))
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
function literalMapEqual(a, b) {
|
|
236
|
+
if (a.size !== b.size)
|
|
237
|
+
return false;
|
|
238
|
+
for (const [k, v] of a) {
|
|
239
|
+
if (!b.has(k) || !Object.is(b.get(k), v))
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
function literalArrayMapEqual(a, b) {
|
|
245
|
+
if (a.size !== b.size)
|
|
246
|
+
return false;
|
|
247
|
+
for (const [k, va] of a) {
|
|
248
|
+
const vb = b.get(k);
|
|
249
|
+
// Value sets are order-stable (built by scanning sites in source order with
|
|
250
|
+
// dedup), so a positional compare is sufficient and avoids set allocation.
|
|
251
|
+
if (!vb || va.length !== vb.length)
|
|
252
|
+
return false;
|
|
253
|
+
for (let i = 0; i < va.length; i++) {
|
|
254
|
+
if (!Object.is(va[i], vb[i]))
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Dead `{#if}` spans per component implied by `plans`, via the SAME shared
|
|
262
|
+
* predicate the transform uses ({@link computeDeadSpans}). A bailed component
|
|
263
|
+
* folds nothing, so it has no dead spans.
|
|
264
|
+
*/
|
|
265
|
+
export function deadSpansForPlans(models, plans) {
|
|
266
|
+
const out = new Map();
|
|
267
|
+
for (const model of models.values()) {
|
|
268
|
+
const plan = plans.get(model.id);
|
|
269
|
+
if (plan.bail)
|
|
270
|
+
continue;
|
|
271
|
+
// Dead spans are derived from the TEMPLATE, which references props by their
|
|
272
|
+
// LOCAL binding name — so the fold/narrow maps (keyed by external prop name)
|
|
273
|
+
// must be remapped here. This MUST match the transform's own remap exactly,
|
|
274
|
+
// or the fixpoint and the edit could disagree on what folds (unsound).
|
|
275
|
+
const spans = computeDeadSpans(model.ast.fragment, remapToLocalNames(plan.constFold, model), remapToLocalNames(plan.narrow, model));
|
|
276
|
+
if (spans.length > 0)
|
|
277
|
+
out.set(model.id, spans);
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Remap a plan map keyed by EXTERNAL prop name (`constFold` / `narrow`) to one
|
|
283
|
+
* keyed by the LOCAL binding name each prop introduces. Call-site analysis and
|
|
284
|
+
* call-site attribute dropping work off the external name (`prop` in `prop:
|
|
285
|
+
* alias`), but every body/template reference uses the local name (`alias`), so
|
|
286
|
+
* substitution, branch folding and CSS must look values up by local. A prop in
|
|
287
|
+
* `constFold`/`narrow` always has a single-identifier local by construction
|
|
288
|
+
* ({@link buildPlan} never folds a `null`-local or shadowed prop), so every entry
|
|
289
|
+
* maps cleanly; an external name with no matching declared local is dropped.
|
|
290
|
+
*/
|
|
291
|
+
export function remapToLocalNames(map, model) {
|
|
292
|
+
if (map.size === 0)
|
|
293
|
+
return map; // common case: nothing folds — share the empty map
|
|
294
|
+
const localByName = new Map();
|
|
295
|
+
for (const decl of model.props ?? []) {
|
|
296
|
+
if (decl.local !== null)
|
|
297
|
+
localByName.set(decl.name, decl.local);
|
|
298
|
+
}
|
|
299
|
+
const out = new Map();
|
|
300
|
+
for (const [name, value] of map) {
|
|
301
|
+
const local = localByName.get(name);
|
|
302
|
+
if (local !== undefined)
|
|
303
|
+
out.set(local, value);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
function buildModelFromInput(file, edges, parseCache) {
|
|
308
|
+
const { id, code } = file;
|
|
309
|
+
const ast = parseCached(id, code, parseCache);
|
|
310
|
+
// Reconstruct the attribution map from the already-resolved edges (docs §2.1):
|
|
311
|
+
// the engine never resolves. Every edge kind is attributable — its `local` is
|
|
312
|
+
// the exact tag a call site renders (a bare name for `default-svelte`/`barrel`,
|
|
313
|
+
// a dotted member for `namespace`) — so all of them feed the value sets through
|
|
314
|
+
// `collectChildCalls`. The Shell already chased barrels/namespaces to the
|
|
315
|
+
// `.svelte` they render, so there is no per-edge resolution or bail left here.
|
|
316
|
+
const imports = new Map();
|
|
317
|
+
for (const edge of edges)
|
|
318
|
+
imports.set(edge.local, edge.to);
|
|
319
|
+
const bailReasons = [];
|
|
320
|
+
// svelte:options accessors / customElement -> public props, never touchable.
|
|
321
|
+
walk(ast.fragment, null, {
|
|
322
|
+
SvelteOptions(node, { next }) {
|
|
323
|
+
for (const a of node.attributes ?? []) {
|
|
324
|
+
if (a.type === 'Attribute' && (a.name === 'accessors' || a.name === 'customElement')) {
|
|
325
|
+
bailReasons.push(`<svelte:options ${a.name}>`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
next();
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
let props = null;
|
|
332
|
+
let propsDeclaration;
|
|
333
|
+
let propsPattern;
|
|
334
|
+
let hasRestProp = false;
|
|
335
|
+
// Every imported local name (svelte or not) — needed for escape detection
|
|
336
|
+
// below. Resolution already happened in the Shell ({@link buildAnalyzeInput});
|
|
337
|
+
// here we only read names off the parse, no IO.
|
|
338
|
+
const importedLocals = new Set();
|
|
339
|
+
// Namespace import locals (`import * as ns`). If `ns` itself is read as a value
|
|
340
|
+
// the whole namespace object escapes, so every `ns.*` component it could render
|
|
341
|
+
// must bail — `collectEscapedComponents` uses this to do so.
|
|
342
|
+
const namespaceLocals = new Set();
|
|
343
|
+
const instance = ast.instance;
|
|
344
|
+
if (instance) {
|
|
345
|
+
for (const imp of importSources(instance)) {
|
|
346
|
+
importedLocals.add(imp.local);
|
|
347
|
+
if (imp.imported === '*')
|
|
348
|
+
namespaceLocals.add(imp.local);
|
|
349
|
+
}
|
|
350
|
+
const found = findPropsDeclaration(instance);
|
|
351
|
+
if (found) {
|
|
352
|
+
propsDeclaration = found.declaration;
|
|
353
|
+
propsPattern = found.pattern;
|
|
354
|
+
// `let { x } = $props(), y = 1;` — the `$props()` destructuring is one of
|
|
355
|
+
// several declarators in its statement. Dropping the now-empty signature
|
|
356
|
+
// removes the whole statement (it has no per-declarator anchor we edit),
|
|
357
|
+
// which would delete the unrelated `y` binding and dangle its template
|
|
358
|
+
// reference. This is rare; bail the whole component conservatively rather
|
|
359
|
+
// than risk corrupting sibling declarations (docs §4.1: when unsure, leave
|
|
360
|
+
// it). The empty `dropped` set then also leaves call-site attributes in
|
|
361
|
+
// place.
|
|
362
|
+
if (found.sharesStatement)
|
|
363
|
+
bailReasons.push('$props() shares a multi-declarator statement');
|
|
364
|
+
props = [];
|
|
365
|
+
for (const p of found.pattern.properties ?? []) {
|
|
366
|
+
if (p.type === 'RestElement') {
|
|
367
|
+
hasRestProp = true;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (p.type !== 'Property')
|
|
371
|
+
continue;
|
|
372
|
+
const key = p.key;
|
|
373
|
+
if (key?.type !== 'Identifier' || !key.name)
|
|
374
|
+
continue;
|
|
375
|
+
// The destructure VALUE is the local binding. A bare identifier (`prop`
|
|
376
|
+
// shorthand, or `prop: alias`) binds that one name; an `AssignmentPattern`
|
|
377
|
+
// (`prop = d` / `prop: alias = d`) binds its LEFT and carries the default;
|
|
378
|
+
// anything else (a nested Object/Array pattern, with or without default)
|
|
379
|
+
// binds no single identifier, so `local` is `null` and the prop is never
|
|
380
|
+
// foldable.
|
|
381
|
+
const value = p.value;
|
|
382
|
+
let local = null;
|
|
383
|
+
let defaultExpr;
|
|
384
|
+
if (value?.type === 'Identifier') {
|
|
385
|
+
local = value.name ?? null;
|
|
386
|
+
}
|
|
387
|
+
else if (value?.type === 'AssignmentPattern') {
|
|
388
|
+
defaultExpr = value.right;
|
|
389
|
+
if (value.left?.type === 'Identifier')
|
|
390
|
+
local = value.left.name ?? null;
|
|
391
|
+
}
|
|
392
|
+
props.push({ name: key.name, local, property: p, defaultExpr });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const childCalls = collectChildCalls(ast, imports);
|
|
397
|
+
const { shadowedNames, debugNames } = collectTemplateBindings(ast, instance, propsDeclaration);
|
|
398
|
+
// Escape detection (docs §4.1): an imported component referenced as a *value*
|
|
399
|
+
// (most notably `<svelte:component this={X}>`, but also assigned / passed /
|
|
400
|
+
// stored) leaks to a use we cannot follow, so its prop profile is incomplete.
|
|
401
|
+
// We surface that to the OWNING component of the escaped child via
|
|
402
|
+
// `escapedComponents`; `analyze` turns it into a complete bail for that child.
|
|
403
|
+
const escapedComponents = collectEscapedComponents(ast, imports, importedLocals, namespaceLocals);
|
|
404
|
+
return {
|
|
405
|
+
id,
|
|
406
|
+
code,
|
|
407
|
+
ast,
|
|
408
|
+
imports,
|
|
409
|
+
props,
|
|
410
|
+
propsDeclaration,
|
|
411
|
+
propsPattern,
|
|
412
|
+
hasRestProp,
|
|
413
|
+
childCalls,
|
|
414
|
+
shadowedNames,
|
|
415
|
+
debugNames,
|
|
416
|
+
escapedComponents,
|
|
417
|
+
bailReasons,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Collect every name bound by a TEMPLATE scope (so a same-named prop is a
|
|
422
|
+
* different entity there) and every name used as a `{@debug}` argument.
|
|
423
|
+
*
|
|
424
|
+
* The instance-script `let`/`function` shadows handled by the old
|
|
425
|
+
* {@link referencedAsBinding} are folded in here too, so one set answers "is
|
|
426
|
+
* this prop name rebound anywhere we'd otherwise wrongly substitute it?".
|
|
427
|
+
*
|
|
428
|
+
* Template binders covered: `{#each expr as ctx, index (key)}` (`ctx` may be a
|
|
429
|
+
* destructure pattern), `{#snippet name(params)}`, `{#await expr then value}` /
|
|
430
|
+
* `{:catch error}`, and `let:foo` directives. All of these introduce bindings
|
|
431
|
+
* the transform's substitution pass is otherwise blind to.
|
|
432
|
+
*/
|
|
433
|
+
function collectTemplateBindings(ast, instance, propsDeclaration) {
|
|
434
|
+
const shadowedNames = new Set();
|
|
435
|
+
const debugNames = new Set();
|
|
436
|
+
// Instance-script `let` / `function` shadows (the original guard's job).
|
|
437
|
+
if (instance) {
|
|
438
|
+
walk(instance, null, {
|
|
439
|
+
_(node, { next }) {
|
|
440
|
+
if ((node.type === 'VariableDeclarator' || node.type === 'FunctionDeclaration') &&
|
|
441
|
+
node !== propsDeclaration &&
|
|
442
|
+
node.id?.type === 'Identifier' &&
|
|
443
|
+
node.id.name) {
|
|
444
|
+
shadowedNames.add(node.id.name);
|
|
445
|
+
}
|
|
446
|
+
// Function / arrow PARAMETERS rebind their names inside the callback
|
|
447
|
+
// body, so a prop sharing a parameter name is a DIFFERENT entity there.
|
|
448
|
+
// Substituting the prop's literal into the parameter slot emits invalid
|
|
449
|
+
// Svelte (`(x) =>` -> `(1) =>` "Assigning to rvalue") and corrupts any
|
|
450
|
+
// shadowed body reference. The destructure/`{#each as}`/snippet-param
|
|
451
|
+
// guard never covered these, so collect them here and bail such props.
|
|
452
|
+
if (node.type === 'FunctionDeclaration' ||
|
|
453
|
+
node.type === 'FunctionExpression' ||
|
|
454
|
+
node.type === 'ArrowFunctionExpression') {
|
|
455
|
+
for (const param of node.params ?? [])
|
|
456
|
+
addPatternNames(param, shadowedNames);
|
|
457
|
+
}
|
|
458
|
+
next();
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
walk(ast.fragment, null, {
|
|
463
|
+
EachBlock(node, { next }) {
|
|
464
|
+
addPatternNames(node.context, shadowedNames);
|
|
465
|
+
if (typeof node.index === 'string')
|
|
466
|
+
shadowedNames.add(node.index);
|
|
467
|
+
next();
|
|
468
|
+
},
|
|
469
|
+
SnippetBlock(node, { next }) {
|
|
470
|
+
// The snippet NAME itself is a binding, and so is every parameter.
|
|
471
|
+
if (node.expression?.type === 'Identifier' && node.expression.name)
|
|
472
|
+
shadowedNames.add(node.expression.name);
|
|
473
|
+
for (const p of node.parameters ?? [])
|
|
474
|
+
addPatternNames(p, shadowedNames);
|
|
475
|
+
next();
|
|
476
|
+
},
|
|
477
|
+
AwaitBlock(node, { next }) {
|
|
478
|
+
// `then` value / `catch` error bindings (`value` is the loose `unknown`).
|
|
479
|
+
addPatternNames(node.value, shadowedNames);
|
|
480
|
+
addPatternNames(node.error, shadowedNames);
|
|
481
|
+
next();
|
|
482
|
+
},
|
|
483
|
+
LetDirective(node, { next }) {
|
|
484
|
+
// `let:foo` binds `foo` (or `let:foo={value}` re-binds it) in the slot.
|
|
485
|
+
if (node.name)
|
|
486
|
+
shadowedNames.add(node.name);
|
|
487
|
+
next();
|
|
488
|
+
},
|
|
489
|
+
ConstTag(node, { next }) {
|
|
490
|
+
// `{@const x = …}` binds `x`; treat it as a shadow too.
|
|
491
|
+
for (const d of node.declaration?.declarations ?? [])
|
|
492
|
+
addPatternNames(d.id, shadowedNames);
|
|
493
|
+
next();
|
|
494
|
+
},
|
|
495
|
+
DebugTag(node, { next }) {
|
|
496
|
+
for (const ident of node.identifiers ?? [])
|
|
497
|
+
if (ident.type === 'Identifier' && ident.name)
|
|
498
|
+
debugNames.add(ident.name);
|
|
499
|
+
next();
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
return { shadowedNames, debugNames };
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Add every identifier bound by a (possibly destructuring) pattern to `out`.
|
|
506
|
+
* Handles bare identifiers, object/array destructuring, defaults and rest.
|
|
507
|
+
*/
|
|
508
|
+
function addPatternNames(pattern, out) {
|
|
509
|
+
if (!pattern)
|
|
510
|
+
return;
|
|
511
|
+
switch (pattern.type) {
|
|
512
|
+
case 'Identifier':
|
|
513
|
+
if (pattern.name)
|
|
514
|
+
out.add(pattern.name);
|
|
515
|
+
return;
|
|
516
|
+
case 'ObjectPattern':
|
|
517
|
+
for (const prop of pattern.properties ?? []) {
|
|
518
|
+
if (prop.type === 'RestElement')
|
|
519
|
+
addPatternNames(prop.argument, out);
|
|
520
|
+
// `{ a }` / `{ a: b }` — the binding is the property *value*.
|
|
521
|
+
else if (prop.type === 'Property')
|
|
522
|
+
addPatternNames(prop.value ?? prop.key, out);
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
case 'ArrayPattern':
|
|
526
|
+
for (const el of pattern.elements ?? [])
|
|
527
|
+
addPatternNames(el, out);
|
|
528
|
+
return;
|
|
529
|
+
case 'AssignmentPattern':
|
|
530
|
+
addPatternNames(pattern.left, out);
|
|
531
|
+
return;
|
|
532
|
+
case 'RestElement':
|
|
533
|
+
addPatternNames(pattern.argument, out);
|
|
534
|
+
return;
|
|
535
|
+
default:
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Imported component ids that ESCAPE — referenced as a value rather than only as
|
|
541
|
+
* a `<Comp .../>` element name. The dominant case is `<svelte:component
|
|
542
|
+
* this={X}>`, where `X` is an ordinary identifier read of the import; once a
|
|
543
|
+
* component leaks like this we can no longer see all the props it receives, so
|
|
544
|
+
* the owner reports it and `analyze` bails the child completely (docs §4.1).
|
|
545
|
+
*
|
|
546
|
+
* We only flag a NAME we resolved to a `.svelte` import. Normal `<X .../>`
|
|
547
|
+
* usage parses as a `Component` whose `name` is a string (not an Identifier
|
|
548
|
+
* node), so it never counts as an escape.
|
|
549
|
+
*/
|
|
550
|
+
function collectEscapedComponents(ast, imports, importedLocals, namespaceLocals) {
|
|
551
|
+
const escaped = new Set();
|
|
552
|
+
const flag = (name) => {
|
|
553
|
+
if (!name)
|
|
554
|
+
return;
|
|
555
|
+
const childId = imports.get(name);
|
|
556
|
+
if (childId)
|
|
557
|
+
escaped.add(childId);
|
|
558
|
+
// A namespace object (`import * as ns`) read as a value can render any of its
|
|
559
|
+
// members dynamically (`const C = ns.X; <svelte:component this={C}/>`), so
|
|
560
|
+
// every `ns.*` component we resolved must bail too.
|
|
561
|
+
if (namespaceLocals.has(name)) {
|
|
562
|
+
for (const [local, id] of imports)
|
|
563
|
+
if (local.startsWith(`${name}.`))
|
|
564
|
+
escaped.add(id);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
walk(ast.fragment, { parent: null }, {
|
|
568
|
+
_(node, { state, next }) {
|
|
569
|
+
if (node.type === 'Identifier' &&
|
|
570
|
+
node.name &&
|
|
571
|
+
importedLocals.has(node.name) &&
|
|
572
|
+
isValueUse(node, state.parent)) {
|
|
573
|
+
flag(node.name);
|
|
574
|
+
}
|
|
575
|
+
next({ parent: node });
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
// The instance script can also leak a component as a value (assign to a var,
|
|
579
|
+
// push into an array, pass to a function, store in a `$state`, etc.).
|
|
580
|
+
if (ast.instance) {
|
|
581
|
+
walk(ast.instance, { parent: null }, {
|
|
582
|
+
_(node, { state, next }) {
|
|
583
|
+
if (node.type === 'Identifier' &&
|
|
584
|
+
node.name &&
|
|
585
|
+
(imports.has(node.name) || namespaceLocals.has(node.name)) &&
|
|
586
|
+
isValueUse(node, state.parent) &&
|
|
587
|
+
!isImportSpecifierPosition(state.parent)) {
|
|
588
|
+
flag(node.name);
|
|
589
|
+
}
|
|
590
|
+
next({ parent: node });
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return escaped;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Is this Identifier used as a runtime *value* (so a component name here would
|
|
598
|
+
* escape)? Property keys, member names and import/export specifier slots are
|
|
599
|
+
* not value reads; everything else conservatively counts as one.
|
|
600
|
+
*/
|
|
601
|
+
function isValueUse(node, parent) {
|
|
602
|
+
if (!parent)
|
|
603
|
+
return false;
|
|
604
|
+
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed)
|
|
605
|
+
return false;
|
|
606
|
+
if (parent.type === 'Property' &&
|
|
607
|
+
parent.key === node &&
|
|
608
|
+
!parent.computed &&
|
|
609
|
+
parent.shorthand !== true)
|
|
610
|
+
return false;
|
|
611
|
+
if (isImportSpecifierPosition(parent))
|
|
612
|
+
return false;
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
function isImportSpecifierPosition(parent) {
|
|
616
|
+
return (parent != null &&
|
|
617
|
+
(parent.type === 'ImportSpecifier' ||
|
|
618
|
+
parent.type === 'ImportDefaultSpecifier' ||
|
|
619
|
+
parent.type === 'ImportNamespaceSpecifier' ||
|
|
620
|
+
parent.type === 'ExportSpecifier'));
|
|
621
|
+
}
|
|
622
|
+
/** Every `<Child .../>` this component renders, paired with its resolved id. */
|
|
623
|
+
function collectChildCalls(ast, imports) {
|
|
624
|
+
const calls = [];
|
|
625
|
+
walk(ast.fragment, null, {
|
|
626
|
+
Component(node, { next }) {
|
|
627
|
+
const childId = node.name ? imports.get(node.name) : undefined;
|
|
628
|
+
if (childId)
|
|
629
|
+
calls.push({ childId, node });
|
|
630
|
+
next();
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
return calls;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* The set of barrel-resolved children this file actually RENDERS as `<Comp/>`.
|
|
637
|
+
* Used by the Shell crawl to enqueue only the barrel children a file renders — an
|
|
638
|
+
* unused barrel import resolves to a component nothing instantiates, so following
|
|
639
|
+
* it would pull a never-rendered file into the program for no reason.
|
|
640
|
+
*/
|
|
641
|
+
function collectBarrelChildIds(ast, barrelLocals) {
|
|
642
|
+
const ids = new Set();
|
|
643
|
+
if (barrelLocals.size === 0)
|
|
644
|
+
return ids;
|
|
645
|
+
walk(ast.fragment, null, {
|
|
646
|
+
Component(node, { next }) {
|
|
647
|
+
const childId = node.name ? barrelLocals.get(node.name) : undefined;
|
|
648
|
+
if (childId)
|
|
649
|
+
ids.add(childId);
|
|
650
|
+
next();
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
return ids;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Every dotted component tag a file renders (`<ns.Child/>` -> `"ns.Child"`). The
|
|
657
|
+
* Shell resolves each through its namespace import's barrel; bare `<Child/>` tags
|
|
658
|
+
* have no dot and are bound by the plain import maps instead.
|
|
659
|
+
*/
|
|
660
|
+
function memberComponentTags(ast) {
|
|
661
|
+
const tags = new Set();
|
|
662
|
+
walk(ast.fragment, null, {
|
|
663
|
+
Component(node, { next }) {
|
|
664
|
+
if (typeof node.name === 'string' && node.name.includes('.'))
|
|
665
|
+
tags.add(node.name);
|
|
666
|
+
next();
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
return tags;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Read one `<Child .../>` into a {@link CallSite}. Attributes are in source
|
|
673
|
+
* order, so we resolve last-write-wins (a later `a={…}` overrides an earlier
|
|
674
|
+
* one) and record, per prop, whether its winning write came *after* the last
|
|
675
|
+
* spread — the only case a spread cannot silently override it (docs §4.1).
|
|
676
|
+
*/
|
|
677
|
+
export function readCallSite(component) {
|
|
678
|
+
const attrs = component.attributes ?? [];
|
|
679
|
+
let lastSpreadIndex = -1;
|
|
680
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
681
|
+
if (attrs[i].type === 'SpreadAttribute')
|
|
682
|
+
lastSpreadIndex = i;
|
|
683
|
+
}
|
|
684
|
+
const explicit = new Map();
|
|
685
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
686
|
+
const attr = attrs[i];
|
|
687
|
+
const name = attr.name;
|
|
688
|
+
if (attr.type === 'BindDirective') {
|
|
689
|
+
// `bind:prop` is a used, dynamic two-way binding (docs §4.1).
|
|
690
|
+
if (name)
|
|
691
|
+
explicit.set(name, dynamicWrite(i, lastSpreadIndex));
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (attr.type !== 'Attribute' || !name)
|
|
695
|
+
continue; // on:/use:/let: are not props
|
|
696
|
+
const lit = literalAttrValue(attr.value);
|
|
697
|
+
// Last-write-wins: a later occurrence of the same name overrides earlier.
|
|
698
|
+
explicit.set(name, lit.known
|
|
699
|
+
? {
|
|
700
|
+
value: lit.value,
|
|
701
|
+
dynamic: false,
|
|
702
|
+
afterLastSpread: i > lastSpreadIndex,
|
|
703
|
+
}
|
|
704
|
+
: dynamicWrite(i, lastSpreadIndex));
|
|
705
|
+
}
|
|
706
|
+
// Svelte 5 synthesizes props from the component's element BODY, not from
|
|
707
|
+
// attributes (docs §4.2: every consumer of a prop must be enumerated):
|
|
708
|
+
// - any non-whitespace, non-comment, non-`{#snippet}` body content sets the
|
|
709
|
+
// `children` prop, and
|
|
710
|
+
// - each `{#snippet name(...)}` in the body sets a prop named `name`.
|
|
711
|
+
// These are real (dynamic) writes the attribute scan above is blind to; if we
|
|
712
|
+
// omitted them, a `children`/named-snippet prop with no attribute would fall
|
|
713
|
+
// back to its default, fold to a constant, and the transform would erase the
|
|
714
|
+
// slotted content. Mark each as a dynamic write that no spread can override
|
|
715
|
+
// (it is supplied positionally, after any spread), so the prop is never folded
|
|
716
|
+
// or dropped. This is conservative — it only ever keeps such a prop.
|
|
717
|
+
for (const name of synthesizedBodyProps(component)) {
|
|
718
|
+
explicit.set(name, dynamicWrite(attrs.length, lastSpreadIndex));
|
|
719
|
+
}
|
|
720
|
+
return { hadSpread: lastSpreadIndex >= 0, explicit };
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Names of the props a `<Child>…</Child>` call site supplies through its element
|
|
724
|
+
* body rather than through attributes: `children` for any renderable body
|
|
725
|
+
* content, plus one entry per named `{#snippet name(...)}` block. Matches
|
|
726
|
+
* Svelte's own rule — pure whitespace and comments do NOT synthesize `children`
|
|
727
|
+
* (verified against the compiler), so a multi-line self-closing-style body does
|
|
728
|
+
* not spuriously keep `children`.
|
|
729
|
+
*/
|
|
730
|
+
function synthesizedBodyProps(component) {
|
|
731
|
+
const nodes = component.fragment?.nodes ?? [];
|
|
732
|
+
const names = [];
|
|
733
|
+
let hasChildren = false;
|
|
734
|
+
for (const node of nodes) {
|
|
735
|
+
if (node.type === 'SnippetBlock') {
|
|
736
|
+
// `{#snippet header()}` supplies the `header` prop.
|
|
737
|
+
if (node.expression?.type === 'Identifier' && node.expression.name)
|
|
738
|
+
names.push(node.expression.name);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (node.type === 'Comment')
|
|
742
|
+
continue;
|
|
743
|
+
if (node.type === 'Text') {
|
|
744
|
+
// Whitespace-only text does not synthesize `children`.
|
|
745
|
+
const text = (node.data ?? node.raw ?? '');
|
|
746
|
+
if (text.trim() === '')
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
hasChildren = true;
|
|
750
|
+
}
|
|
751
|
+
if (hasChildren)
|
|
752
|
+
names.push('children');
|
|
753
|
+
return names;
|
|
754
|
+
}
|
|
755
|
+
function dynamicWrite(index, lastSpreadIndex) {
|
|
756
|
+
return {
|
|
757
|
+
value: undefined,
|
|
758
|
+
dynamic: true,
|
|
759
|
+
afterLastSpread: index > lastSpreadIndex,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/** Extract a literal from an attribute value, or `{ known:false }`. */
|
|
763
|
+
function literalAttrValue(value) {
|
|
764
|
+
if (value === true)
|
|
765
|
+
return { known: true, value: true }; // boolean shorthand
|
|
766
|
+
if (value == null)
|
|
767
|
+
return { known: false };
|
|
768
|
+
const parts = (Array.isArray(value) ? value : [value]);
|
|
769
|
+
if (parts.length === 1) {
|
|
770
|
+
const part = parts[0];
|
|
771
|
+
if (part.type === 'Text')
|
|
772
|
+
return { known: true, value: (part.data ?? part.raw ?? '') };
|
|
773
|
+
if (part.type === 'ExpressionTag' && part.expression?.type === 'Literal') {
|
|
774
|
+
return { known: true, value: part.expression.value };
|
|
775
|
+
}
|
|
776
|
+
return { known: false };
|
|
777
|
+
}
|
|
778
|
+
// Multiple parts: only fold when every part is static text.
|
|
779
|
+
let text = '';
|
|
780
|
+
for (const part of parts) {
|
|
781
|
+
if (part.type !== 'Text')
|
|
782
|
+
return { known: false };
|
|
783
|
+
text += (part.data ?? part.raw ?? '');
|
|
784
|
+
}
|
|
785
|
+
return { known: true, value: text };
|
|
786
|
+
}
|
|
787
|
+
/** Decide what to fold for one component from its global usage. */
|
|
788
|
+
/**
|
|
789
|
+
* Whether a declared prop name is unsafe to fold/narrow/drop because it is also
|
|
790
|
+
* bound elsewhere: shadowed by a local `let`/`function` or a template binder
|
|
791
|
+
* (`{#each as}`, snippet params, `{#await then}`, `let:`, `{@const}`), or used as
|
|
792
|
+
* a `{@debug}` argument (Svelte forbids a literal there). In those scopes the
|
|
793
|
+
* name is a different entity, so folding it would corrupt the binding (often
|
|
794
|
+
* invalid Svelte). Both L1 planning ({@link buildPlan}) and L2 specialization
|
|
795
|
+
* (mono.ts) must honor this identically.
|
|
796
|
+
*/
|
|
797
|
+
export function isFoldBlockedName(model, name) {
|
|
798
|
+
return model.shadowedNames.has(name) || model.debugNames.has(name);
|
|
799
|
+
}
|
|
800
|
+
function buildPlan(model, u) {
|
|
801
|
+
const plan = emptyPlan(model.id);
|
|
802
|
+
if (model.bailReasons.length > 0) {
|
|
803
|
+
plan.bail = true;
|
|
804
|
+
plan.reasons.push(...model.bailReasons);
|
|
805
|
+
return plan;
|
|
806
|
+
}
|
|
807
|
+
if (!model.props || model.props.length === 0)
|
|
808
|
+
return plan;
|
|
809
|
+
// NOTE: a `...rest` in the *callee* never captures the callee's own declared
|
|
810
|
+
// props — rest only holds UNDECLARED props (docs §4.1). So folding/dropping a
|
|
811
|
+
// declared prop stays sound even when `...rest` exists; we do not bail here.
|
|
812
|
+
const sites = u?.sites ?? [];
|
|
813
|
+
if (sites.length === 0)
|
|
814
|
+
return plan; // entry / unused: leave as-is
|
|
815
|
+
for (const decl of model.props) {
|
|
816
|
+
// A `null` local is a nested-pattern entry (`prop: { x }`): there is no single
|
|
817
|
+
// identifier to substitute or drop, so it is never foldable — folding it would
|
|
818
|
+
// delete the inner binding. The shadow guard tests the LOCAL name (the entity
|
|
819
|
+
// the body actually references): a name also bound elsewhere is a different
|
|
820
|
+
// entity, so folding it corrupts that binding. L2 specialization honors the
|
|
821
|
+
// SAME two predicates (see mono.ts).
|
|
822
|
+
if (decl.local === null || isFoldBlockedName(model, decl.local))
|
|
823
|
+
continue;
|
|
824
|
+
const set = valueSetFor(decl, sites);
|
|
825
|
+
plan.valueSets.set(decl.name, set);
|
|
826
|
+
// `top` (a spread may set it) and `dynamic` (a non-literal write) both
|
|
827
|
+
// poison the set: the reachable values are not fully known, so neither
|
|
828
|
+
// folding nor narrowing is sound.
|
|
829
|
+
if (set.top || set.dynamic)
|
|
830
|
+
continue;
|
|
831
|
+
// L1: a clean singleton value set is the foldable case.
|
|
832
|
+
if (set.values.length === 1) {
|
|
833
|
+
plan.constFold.set(decl.name, set.values[0]);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
// L1.5: >= 2 distinct literals with no dynamic/⊤ contribution is a fully
|
|
837
|
+
// known reachable value set — branches the prop can never reach are dead
|
|
838
|
+
// (docs §3 L1.5). The prop stays genuinely used, so it is only recorded for
|
|
839
|
+
// narrowing, never for substitution/dropping.
|
|
840
|
+
if (set.values.length >= 2)
|
|
841
|
+
plan.narrow.set(decl.name, set.values);
|
|
842
|
+
}
|
|
843
|
+
return plan;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Join one declared prop's value over every call site into a {@link
|
|
847
|
+
* PropValueSet} (docs §2.2). Partial bail (docs §4.1): a prop is `top` as soon
|
|
848
|
+
* as ANY site has a spread but does not pass it *explicitly after that spread*,
|
|
849
|
+
* because the spread may then silently set it. Sites with no spread that omit
|
|
850
|
+
* the prop contribute its default value.
|
|
851
|
+
*/
|
|
852
|
+
function valueSetFor(decl, sites) {
|
|
853
|
+
const values = [];
|
|
854
|
+
let dynamic = false;
|
|
855
|
+
let top = false;
|
|
856
|
+
const add = (v) => {
|
|
857
|
+
if (!values.some((x) => Object.is(x, v)))
|
|
858
|
+
values.push(v);
|
|
859
|
+
};
|
|
860
|
+
for (const site of sites) {
|
|
861
|
+
const explicit = site.explicit.get(decl.name);
|
|
862
|
+
if (explicit?.afterLastSpread) {
|
|
863
|
+
// Safely explicit: a later attribute, so no spread can override it.
|
|
864
|
+
if (explicit.dynamic)
|
|
865
|
+
dynamic = true;
|
|
866
|
+
else
|
|
867
|
+
add(explicit.value);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (site.hadSpread) {
|
|
871
|
+
// Not passed (or passed before the spread) while a spread is present:
|
|
872
|
+
// the spread may set this prop -> Unknown (⊤) for this site.
|
|
873
|
+
top = true;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
// No spread and not explicit here -> the prop falls back to its default.
|
|
877
|
+
const def = literalDefault(decl.defaultExpr);
|
|
878
|
+
if (def.known)
|
|
879
|
+
add(def.value);
|
|
880
|
+
else
|
|
881
|
+
dynamic = true; // non-literal / unevaluable default -> cannot fold
|
|
882
|
+
}
|
|
883
|
+
return { values, dynamic, top };
|
|
884
|
+
}
|
|
885
|
+
function literalDefault(expr) {
|
|
886
|
+
if (!expr)
|
|
887
|
+
return { known: true, value: undefined }; // omitted default -> undefined
|
|
888
|
+
if (expr.type === 'Literal')
|
|
889
|
+
return { known: true, value: expr.value };
|
|
890
|
+
if (expr.type === 'Identifier' && expr.name === 'undefined')
|
|
891
|
+
return { known: true, value: undefined };
|
|
892
|
+
return { known: false };
|
|
893
|
+
}
|
|
894
|
+
function* importSources(instance) {
|
|
895
|
+
const program = instance.content;
|
|
896
|
+
for (const stmt of program?.body ?? []) {
|
|
897
|
+
if (stmt.type !== 'ImportDeclaration')
|
|
898
|
+
continue;
|
|
899
|
+
const value = stmt.source?.value;
|
|
900
|
+
if (typeof value !== 'string')
|
|
901
|
+
continue;
|
|
902
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
903
|
+
const local = spec.local?.name;
|
|
904
|
+
if (!local)
|
|
905
|
+
continue;
|
|
906
|
+
if (spec.type === 'ImportDefaultSpecifier')
|
|
907
|
+
yield { value, local, imported: 'default' };
|
|
908
|
+
else if (spec.type === 'ImportNamespaceSpecifier')
|
|
909
|
+
yield { value, local, imported: '*' };
|
|
910
|
+
else if (spec.type === 'ImportSpecifier')
|
|
911
|
+
// `import { Child as ChildB }` — `imported` is the source's export name.
|
|
912
|
+
yield {
|
|
913
|
+
value,
|
|
914
|
+
local,
|
|
915
|
+
imported: importedName(spec) ?? local,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/** The exported name an `ImportSpecifier` pulls in (`imported`, falling back to
|
|
921
|
+
* `local` for shorthand `import { X }`). */
|
|
922
|
+
function importedName(spec) {
|
|
923
|
+
const imported = spec.imported;
|
|
924
|
+
if (imported?.type === 'Identifier' && imported.name)
|
|
925
|
+
return imported.name;
|
|
926
|
+
// Some parsers expose a string-literal `imported` (`import { "x" as y }`).
|
|
927
|
+
if (imported?.type === 'Literal' && typeof imported.value === 'string')
|
|
928
|
+
return imported.value;
|
|
929
|
+
return undefined;
|
|
930
|
+
}
|
|
931
|
+
/** The local/exported name strings of an Export/Import specifier. */
|
|
932
|
+
function specName(node) {
|
|
933
|
+
if (node?.type === 'Identifier' && node.name)
|
|
934
|
+
return node.name;
|
|
935
|
+
if (node?.type === 'Literal' && typeof node.value === 'string')
|
|
936
|
+
return node.value;
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
/** Cap on how many `.js`/`.ts` barrel hops we follow before giving up. */
|
|
940
|
+
const MAX_BARREL_HOPS = 8;
|
|
941
|
+
/**
|
|
942
|
+
* Follow a NON-direct import (named / namespace, or a default import of a
|
|
943
|
+
* `.js`/`.ts` barrel) to the `.svelte` component it ultimately renders, if any.
|
|
944
|
+
*
|
|
945
|
+
* The dangerous case (docs §4.2): a child reached BOTH directly and through a
|
|
946
|
+
* barrel re-export — `import { Child } from './lib.js'` where `lib.js` is
|
|
947
|
+
* `export { default as Child } from './Child.svelte'`. The `<Child/>` site in
|
|
948
|
+
* the barrel-consuming file is invisible to {@link collectChildCalls}, so the
|
|
949
|
+
* child's value set would omit it and fold unsoundly. We resolve through the
|
|
950
|
+
* barrel here so `analyze` can bail that child. When the source resolves to a
|
|
951
|
+
* `.svelte` default we return it; through a `.js`/`.ts` we read the module and
|
|
952
|
+
* follow `export … from`, `export *`, and re-exported local imports. Anything
|
|
953
|
+
* we cannot follow returns `null` — sound, because a child we never resolve is
|
|
954
|
+
* never planned (a pure-barrel `.js` component is simply out of scope).
|
|
955
|
+
*/
|
|
956
|
+
async function resolveThroughBarrel(source, imported, importer, resolve, readFile, hops = 0) {
|
|
957
|
+
if (hops > MAX_BARREL_HOPS)
|
|
958
|
+
return null;
|
|
959
|
+
const targetId = await resolve(source, importer);
|
|
960
|
+
if (!targetId)
|
|
961
|
+
return null;
|
|
962
|
+
// A `.svelte` reached by default (or namespace, whose `.default` is the
|
|
963
|
+
// component) renders that component. A NAMED import of a `.svelte` cannot name
|
|
964
|
+
// a component (`.svelte` only exports `default`), so it never renders one.
|
|
965
|
+
if (isSvelte(source) || isSvelte(targetId)) {
|
|
966
|
+
return imported === 'default' || imported === '*' ? targetId : null;
|
|
967
|
+
}
|
|
968
|
+
// A `.js`/`.ts` barrel: read it and chase the matching re-export.
|
|
969
|
+
let code;
|
|
970
|
+
try {
|
|
971
|
+
code = await readFile(targetId);
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
const body = parseModuleBody(code, targetId);
|
|
977
|
+
if (!body)
|
|
978
|
+
return null;
|
|
979
|
+
for (const stmt of body) {
|
|
980
|
+
// `export { local as exported } from './x'` / `export { default } from`.
|
|
981
|
+
if (stmt.type === 'ExportNamedDeclaration' && stmt.source?.value) {
|
|
982
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
983
|
+
if (specName(spec.exported) !== imported)
|
|
984
|
+
continue;
|
|
985
|
+
return resolveThroughBarrel(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, hops + 1);
|
|
986
|
+
}
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
// `export { D as Child }` (no `from`) — re-export of a LOCAL import binding.
|
|
990
|
+
if (stmt.type === 'ExportNamedDeclaration' && !stmt.source) {
|
|
991
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
992
|
+
if (specName(spec.exported) !== imported)
|
|
993
|
+
continue;
|
|
994
|
+
const localName = specName(spec.local);
|
|
995
|
+
if (!localName)
|
|
996
|
+
continue;
|
|
997
|
+
const found = followLocalImport(body, localName);
|
|
998
|
+
if (!found)
|
|
999
|
+
return null;
|
|
1000
|
+
return resolveThroughBarrel(found.value, found.imported, targetId, resolve, readFile, hops + 1);
|
|
1001
|
+
}
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
// `export * from './x'` — the name may live behind the wildcard.
|
|
1005
|
+
if (stmt.type === 'ExportAllDeclaration' && stmt.source?.value) {
|
|
1006
|
+
const via = await resolveThroughBarrel(String(stmt.source.value), imported, targetId, resolve, readFile, hops + 1);
|
|
1007
|
+
if (via)
|
|
1008
|
+
return via;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
/** Find the import in `body` that binds `localName`, as an {@link ImportInfo}. */
|
|
1014
|
+
function followLocalImport(body, localName) {
|
|
1015
|
+
for (const stmt of body) {
|
|
1016
|
+
if (stmt.type !== 'ImportDeclaration')
|
|
1017
|
+
continue;
|
|
1018
|
+
const value = stmt.source?.value;
|
|
1019
|
+
if (typeof value !== 'string')
|
|
1020
|
+
continue;
|
|
1021
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
1022
|
+
if (spec.local?.name !== localName)
|
|
1023
|
+
continue;
|
|
1024
|
+
if (spec.type === 'ImportDefaultSpecifier')
|
|
1025
|
+
return { value, imported: 'default' };
|
|
1026
|
+
if (spec.type === 'ImportNamespaceSpecifier')
|
|
1027
|
+
return { value, imported: '*' };
|
|
1028
|
+
if (spec.type === 'ImportSpecifier')
|
|
1029
|
+
return { value, imported: importedName(spec) ?? localName };
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Parse a `.js`/`.ts` module's top-level body by reusing the Svelte parser via a
|
|
1036
|
+
* `<script module>` wrapper (the engine has no standalone JS parser). `lang="ts"`
|
|
1037
|
+
* is required so TypeScript barrels parse — `export type { … }`, type-only
|
|
1038
|
+
* specifiers and annotations are the norm for a design-system's `index.ts`, and a
|
|
1039
|
+
* plain JS parse throws on them, leaving the whole library unfollowed. Returns
|
|
1040
|
+
* `null` if it cannot be parsed — callers then leave the barrel unfollowed.
|
|
1041
|
+
*/
|
|
1042
|
+
function parseModuleBody(code, id) {
|
|
1043
|
+
try {
|
|
1044
|
+
const ast = parseSvelte(`<script module lang="ts">\n${code}\n</script>`, id);
|
|
1045
|
+
return ast.module?.content?.body ?? null;
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
function findPropsDeclaration(instance) {
|
|
1052
|
+
const program = instance.content;
|
|
1053
|
+
for (const stmt of program?.body ?? []) {
|
|
1054
|
+
if (stmt.type !== 'VariableDeclaration')
|
|
1055
|
+
continue;
|
|
1056
|
+
for (const decl of stmt.declarations ?? []) {
|
|
1057
|
+
const init = decl.init;
|
|
1058
|
+
const id = decl.id;
|
|
1059
|
+
if (init?.type === 'CallExpression' &&
|
|
1060
|
+
init.callee?.type === 'Identifier' &&
|
|
1061
|
+
init.callee.name === '$props' &&
|
|
1062
|
+
id?.type === 'ObjectPattern') {
|
|
1063
|
+
return {
|
|
1064
|
+
declaration: stmt,
|
|
1065
|
+
pattern: id,
|
|
1066
|
+
sharesStatement: (stmt.declarations?.length ?? 1) > 1,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|