svelte-shaker 0.12.0 → 0.14.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/README.md CHANGED
@@ -4,76 +4,60 @@
4
4
 
5
5
  <h1 align="center">svelte-shaker</h1>
6
6
 
7
- <p align="center">A <strong>sound, source-level tree-shaker for Svelte&nbsp;5 (runes) components.</strong></p>
8
-
9
- **▶ Try it in the browser: https://baseballyama.github.io/svelte-shaker/** — an
10
- interactive playground that runs the engine entirely client-side (and is itself
11
- built with rsvelte + dogfooded through svelte-shaker).
12
-
13
- It runs in your app's production build, _before_ the Svelte compiler, and slims
14
- each `.svelte` file by partially evaluating it against how the **whole app**
15
- actually uses it: props that are never passed (or always passed the same value)
16
- are folded to their constant, the dead `{#if}` arms behind them are deleted,
17
- those props are dropped from the `$props()` signature, and the now-pointless
18
- attributes are removed at every call site. The Svelte compiler then only sees the
19
- code your app can actually reach.
20
-
21
- It is **sound first**: it never changes what the user sees. When it cannot prove
22
- a transform is safe, it leaves the code untouched (bails).
23
-
24
- See [`docs/ARCHITECTURE.md`](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md) for the full design.
25
-
26
- ## Why this exists (and why a JS bundler can't do it)
27
-
28
- Design-system components carry lots of props (`Button` with
29
- `variant / size / loading / icon / iconPosition / fullWidth / rounded / href …`),
30
- but any one app uses only a few. The code behind the unused props — template
31
- branches, class computation, reactive statements, imports, CSS is effectively
32
- dead for that app, yet it ships anyway.
33
-
34
- It cannot be removed _after_ Svelte compiles, because Svelte emits **one generic
35
- JS module per component**, shared by every caller. In that JS the prop values
36
- flow through the runtime (`$.prop(...)`), so `loading` / `variant` are not static
37
- JS constants — terser/esbuild/Rollup cannot fold `if (loading)` to `if (false)`,
38
- and the single module has no whole-program information to know which props this
39
- particular app never uses.
40
-
41
- svelte-shaker works **one step earlier**, on the pre-compile Svelte source, where
42
- the prop's value (its default, or the literal at the call site) is still visible
43
- and the template structure is intact. It is essentially a **whole-program partial
44
- evaluator + dead-code eliminator that understands Svelte**, driven by every call
45
- site in the app.
46
-
47
- ### The CSS differentiator (what a bundler genuinely can't reach)
48
-
49
- Given `class="btn btn-{variant}"` where the app only ever passes
50
- `variant ∈ {primary, secondary}`, the class `btn-danger` can never exist at
51
- runtime. But the class only appears as a runtime string, so:
52
-
53
- - Svelte's own unused-CSS pruning keeps `.btn-danger` (it can't see inside the
54
- interpolation), and
55
- - Rollup/terser can't touch it either (the class isn't in the JS at all).
56
-
57
- svelte-shaker computes the reachable value set of `variant`, proves the
58
- `.btn-danger` / `.btn-ghost` rules can never match any element this component
59
- renders, and **removes those `<style>` rules** — while keeping `.btn`,
60
- `.btn-primary`, `.btn-secondary`. This is verified end-to-end in
61
- `packages/svelte-shaker/tests/css.test.ts`.
7
+ <p align="center">
8
+ <strong>A sound, source-level tree-shaker for Svelte&nbsp;5 (runes) components.</strong>
9
+ </p>
10
+
11
+ **▶ Try it in the browser: https://baseballyama.github.io/svelte-shaker/** — the
12
+ playground runs the engine entirely client-side.
13
+
14
+ svelte-shaker runs in your production build, **before** the Svelte compiler, and
15
+ slims each `.svelte` file by partially evaluating it against how your **whole
16
+ app** actually uses it: props no call site passes (or that always receive the
17
+ same value) are folded to their constant, the dead `{#if}` arms behind them are
18
+ deleted, the props are dropped from `$props()`, the attributes are removed at
19
+ every call site, and `<style>` rules whose class can never be produced are
20
+ stripped.
21
+
22
+ It is **sound first**: it never changes what renders. When a transform can't be
23
+ proven safe, the code is left untouched (bails).
24
+
25
+ ## Why a JS bundler can't do this
26
+
27
+ Design-system components carry many props (`variant / size / loading / icon …`),
28
+ but any one app uses only a few — yet the code behind the unused props still
29
+ ships. A minifier _can_ fold a component-local constant (it compiles to plain
30
+ JS). A prop is different: Svelte emits **one generic JS module per component**,
31
+ shared by every caller, and the prop's value reaches it through runtime
32
+ indirection (`$.prop(...)`), so turning `if (loading)` into `if (false)` would
33
+ take constant propagation across component boundaries — something neither
34
+ Rollup nor terser performs, even when every call site passes the same literal.
35
+ svelte-shaker works one step earlier, on the pre-compile source, where
36
+ call-site values and template structure are still visible.
37
+
38
+ The clearest win is CSS: given `class="btn btn-{variant}"` where the app only
39
+ ever passes `primary` / `secondary`, the class `btn-danger` can never exist at
40
+ runtime — but it only appears as a runtime string, so neither Svelte's own
41
+ unused-CSS pruning nor the bundler can prove that. svelte-shaker computes the
42
+ reachable value set of `variant` and removes the `.btn-danger` rule.
62
43
 
63
44
  ## Install
64
45
 
65
46
  ```sh
66
- pnpm add -D svelte-shaker
67
- # or: npm i -D svelte-shaker / yarn add -D svelte-shaker
47
+ npm i -D svelte-shaker # requires svelte@^5
68
48
  ```
69
49
 
70
- Requires `svelte@^5`.
50
+ Nothing else to install. By default the plugin parses with rsvelte, loaded from
51
+ `@rsvelte/compiler` (a bundled WASM dependency — no peer, no platform-specific
52
+ binary). `parser: 'svelte'` falls back to svelte/compiler if you ever need it
53
+ (see [Options](#options)).
71
54
 
72
55
  ## Usage (Vite)
73
56
 
74
- Add the plugin **before** `svelte()` so it hands already-slimmed source to the
75
- Svelte compiler. It is **build-only by design** dev is a pass-through (see
76
- [Soundness](#soundness) / [Limitations](#limitations)).
57
+ Add the plugin **before** `svelte()`. It runs only in `vite build` — dev/HMR is
58
+ a pass-through by design. Out of the box it runs the native **Rust (WASM)
59
+ engine** and parses with **rsvelte**; both fall back cleanly (see
60
+ [Options](#options)).
77
61
 
78
62
  ```ts
79
63
  // vite.config.ts
@@ -83,161 +67,162 @@ import { shaker } from 'svelte-shaker/vite';
83
67
 
84
68
  export default defineConfig({
85
69
  plugins: [
86
- // `include` must cover EVERY call site in the app, or prop elimination
87
- // would be unsound. Defaults to the Vite root.
88
- shaker({ include: ['src'] }),
70
+ // `entries` is where the component crawl STARTS, not a file filter. It
71
+ // must cover EVERY call site in the app, or prop elimination would be
72
+ // unsound. Defaults to the Vite root.
73
+ shaker({ entries: ['src'] }),
89
74
  svelte(),
90
75
  ],
91
76
  });
92
77
  ```
93
78
 
94
- There is also a plain-Rollup plugin (`rollup-plugin-svelte-shaker`) for non-Vite
95
- pipelines; the Vite plugin is preferred for apps.
79
+ For plain-Rollup pipelines, wire the shake up yourself with the public engine
80
+ API (`svelte-shaker`) and the file-system helpers in `svelte-shaker/node`. Note
81
+ that monomorphization additionally needs the `?shaker_variant` requests routed
82
+ through your plugin's `resolveId`/`load` hooks; the unused-prop fold / constant
83
+ fold / value-set narrowing shake only needs the `transform` swap. The
84
+ environment-free engine and the in-browser playground parse with svelte/compiler
85
+ — the rsvelte default is a Vite-plugin concern (it loads a Node-only WASM
86
+ module); the engine takes an optional `parse` argument if you want to swap it.
96
87
 
97
88
  ### Options
98
89
 
99
90
  ```ts
100
91
  shaker({
101
- include: ['src'], // dirs (relative to root) holding every .svelte call site
102
- level: 2, // 0 | 1 | 2 default 2 (L0/L1/L1.5 always on; 2 also enables L2).
103
- monomorphize: true, // L2 tuning; on by default object overrides maxVariants/minSavings.
104
- engine: 'auto', // 'auto' (default) | 'js' | 'rust' see below.
105
- parser: 'svelte', // 'svelte' (default) | 'rsvelte' see below.
92
+ entries: ['src'], // dirs (relative to root) the crawl starts from; they must
93
+ // hold every .svelte call site in the app. Not a glob, not a filter.
94
+ preserve: [], // components whose props must never be folded (see below)
95
+ monomorphize: true, // default on; `false` disables it for faster builds,
96
+ // or { maxVariants: 16, minSavings: 0.05 } to tune
97
+ verbose: false, // true = per-file size breakdown after the build
98
+
99
+ // Escape hatches — the defaults ARE the Rust path; set these only to opt
100
+ // out (e.g. if you ever hit a bug in it).
101
+ engine: 'auto', // 'auto' (default: Rust/WASM, else JS) | 'js' | 'rust'
102
+ parser: 'rsvelte', // 'rsvelte' (default) | 'svelte' (fallback)
106
103
  });
107
-
108
- // L2 is ON by default (it is bail-safe and never bloats). Turn it OFF for faster
109
- // builds, or tune it:
110
- shaker({ include: ['src'], level: 1 }); // L2 off (L0/L1/L1.5 only)
111
- shaker({ include: ['src'], monomorphize: { maxVariants: 16 } }); // raise the variant cap
112
-
113
- // Engine: the native Rust (WASM) engine runs the whole shake INCLUDING L2 (it
114
- // calls back to JS only for the net-win gate's compiled-size proxy) and is
115
- // differentially tested to be byte-identical to the JS engine, so it only changes
116
- // speed. It is the default ('auto'); force it (or the JS engine) explicitly with:
117
- shaker({ include: ['src'], engine: 'rust' }); // or engine: 'js'
118
-
119
- // Opt into the faster rsvelte parser (~1.46x full build, ~2.2x parse).
120
- // Requires the optional peer `@rsvelte/vite-plugin-svelte-native` (install it
121
- // yourself). Soundness is unchanged — it only affects speed and, occasionally,
122
- // shakes a little more. If the native package can't load it THROWS (no silent
123
- // fallback) so the output stays the same on every machine.
124
- shaker({ include: ['src'], parser: 'rsvelte' });
125
- ```
126
-
127
- ### The rsvelte (Rust) parser
128
-
129
- By default the engine parses with `svelte/compiler`. Setting `parser: 'rsvelte'`
130
- swaps in [rsvelte](https://github.com/rsvelte/rsvelte)'s native (Rust) parser,
131
- which dominates the shake pipeline (~85% of the time is parsing): on a real
132
- 474-component app the full build runs **~1.46x faster** (parse alone ~2.2x).
133
-
134
- ```sh
135
- # rsvelte's native parser is an OPTIONAL peer — install it to opt in:
136
- pnpm add -D @rsvelte/vite-plugin-svelte-native
137
- ```
138
-
139
- ```ts
140
- // vite.config.ts
141
- shaker({ include: ['src'], parser: 'rsvelte' });
142
104
  ```
143
105
 
144
- - **Soundness is parser-independent.** The engine reads only UTF-16
145
- `start`/`end` offsets, so the chosen parser never changes _what_ is folded
146
- only how fast. The few differences from the `svelte/compiler` path are cases
147
- where rsvelte happens to shake a little _more_, each still behavior-preserving.
148
- - **No silent fallback.** If `parser: 'rsvelte'` is requested but the native
149
- package can't be loaded (not installed, or no prebuilt binary for the
150
- platform), the plugin **throws** rather than quietly using `svelte/compiler` —
151
- a silent fallback would make the same source shake differently depending on
152
- whether the optional binary is present, breaking build reproducibility.
153
-
154
- See [`docs/RUST-MIGRATION.md`](https://github.com/baseballyama/svelte-shaker/blob/main/docs/RUST-MIGRATION.md)
155
- for the design.
156
-
157
- ## What it does
158
-
159
- | Level | What it removes | Default |
160
- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
161
- | **L0** | Props no call site ever passes fold to the default, drop from `$props()`, strip the attribute at call sites | on |
162
- | **L1** | Props that collapse to one constant app-wide fold + drop + strip every call site's attribute | on |
163
- | **L1.5** | Value-set **narrowing**: with `variant {primary, secondary}`, delete provably-dead `{#if}`/`{:else if}` arms (prop stays in the signature) | on |
164
- | **CSS** | `<style>` rules whose class can never be produced given the value sets — the bundler-can't differentiator | on |
165
- | **L2** | Per-call-site monomorphization: specialize a component per prop shape (deduped by residual, capped by `maxVariants`) | on (set `level: 1` to disable) |
106
+ That list is exhaustive: any other key **fails the build**, naming the key and the
107
+ options that do exist. A typo would otherwise be ignored and a misspelled
108
+ `preserve` ships the component you meant to protect, over-shaken.
109
+
110
+ - **The defaults are the Rust path.** Out of the box svelte-shaker runs the
111
+ native **Rust (WASM) engine** and parses with **rsvelte**, loaded from
112
+ [`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
113
+ dependency nothing to install). The Rust (WASM) engine is differentially
114
+ tested to shake **byte-identically** to the JS engine. The parser choice is
115
+ **soundness-neutral**: the engine reads only UTF-16 `start`/`end`, so
116
+ svelte/compiler and rsvelte are differentially tested to produce
117
+ **SSR-equivalent** output — the choice never changes what renders. rsvelte is
118
+ the default as the Rust parser the pipeline is standardizing on (end-to-end
119
+ Rust with the WASM engine); `parser: 'svelte'` stays available as the
120
+ fallback.
121
+ - **`monomorphize`** the one shaking knob, **on** by default. A measured
122
+ net-win gate only specializes a component when that strictly shrinks the whole
123
+ program, so monomorphization **never bloats**: whatever the knobs are set to,
124
+ a build with `monomorphize` on is never larger, byte for byte, than the same
125
+ build with it off (the 3 always-on passes alone). The knobs only trade off how
126
+ much specialization is *attempted* against build time:
127
+ - `maxVariants` (default `8`) cap on distinct residual variants per
128
+ component. A child whose call sites produce more distinct shapes than the
129
+ cap can't be specialized at every site, so it keeps its base entirely
130
+ (all-sites-or-nothing — no partial split). Raise it for a large
131
+ design-system component (e.g. a `Button` used with more than 8 prop shapes
132
+ app-wide) you know is worth specializing further.
133
+ - `minSavings` (default `0`, i.e. any strict net reduction) — the net-win
134
+ threshold: a specialization is applied only when it measures
135
+ `Σ_spec < Σ_base × (1 − minSavings)`. Raising it only makes the gate more
136
+ conservative (fewer, bigger wins, faster builds) — no value makes
137
+ monomorphization unsound.
138
+
139
+ ```ts
140
+ monomorphize: { maxVariants: 16, minSavings: 0.05 } // e.g. a variant-heavy
141
+ // design system, while skipping specializations that save under 5%
142
+ ```
143
+ - **Escape hatches (`engine` / `parser`).** If you ever hit a bug in the Rust
144
+ path, opt out per axis: `engine: 'js'` forces the JS engine, `parser: 'svelte'`
145
+ forces svelte/compiler (the previous default). `@rsvelte/compiler` is a bundled
146
+ dependency, so the default parser normally just loads; in the unlikely event it
147
+ can't (a broken install), the plugin **throws** rather than silently falling
148
+ back — so the same source always shakes the same on every machine. Reinstall
149
+ dependencies, or set `parser: 'svelte'`.
150
+ - **`preserve`** — keep a component's **prop interface** exactly as written, because
151
+ something the shake can't see passes props to it. What is preserved is the props,
152
+ **not** the file's presence in the bundle: this is unrelated to Rollup/Vite's
153
+ `external`, and it never keeps a file out of the bundle or out of the analysis.
154
+
155
+ You need it when the consumer lives outside the `.svelte` graph and the shaker
156
+ can't observe the call site — a `mount()` behind a **non-literal** dynamic
157
+ `import(expr)`, or a module outside the `entries` roots. Consumers reached by a
158
+ static import, `export … from`, or a **literal** `import('./X.svelte')` are found
159
+ by the plugin's own scan of your non-`.svelte` modules, so a plain
160
+ `mount(Component, { props })` is already handled for you.
161
+
162
+ Each entry is a root-relative or absolute path naming a component file (with its
163
+ `.svelte` extension) or a directory of them (same path-prefix basis as `entries`).
164
+ The file stays fully analyzed and its own call sites still count toward its
165
+ children — only that component's own prop folding is turned off. It is not a
166
+ scan-exclusion filter.
167
+
168
+ **When in doubt, list it.** Unlike `entries`, over-listing errs safe: a component
169
+ preserved without needing it is just shaken less, never wrongly.
170
+
171
+ The build **warns** (with the file path) about any module the scan couldn't
172
+ parse — so a mounted component isn't silently left unprotected — and about
173
+ `preserve` entries that matched no component.
174
+
175
+ ## What it removes
176
+
177
+ | Pass | What it removes | Default |
178
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
179
+ | **unused-prop fold** | Props no call site ever passes → fold to the default, drop from `$props()`, strip the attribute at call sites | on |
180
+ | **constant fold** | Props that collapse to one constant app-wide → fold + drop + strip every call site's attribute | on |
181
+ | **value-set narrowing** | With `variant ∈ {primary, secondary}`, delete provably-dead `{#if}`/`{:else if}` arms (prop stays in the signature) | on |
182
+ | **CSS** | `<style>` rules whose class can never be produced given the value sets | on |
183
+ | **monomorphization** | Per-call-site: specialize a component per prop shape (deduped by residual, capped by `maxVariants`) | on (`monomorphize: false` to disable) |
166
184
 
167
185
  Folding also reaches template ternaries (`{cond ? a : b}`) and class-string
168
- interpolation when the condition/parts are provable constants.
186
+ interpolation when the parts are provable constants.
169
187
 
170
188
  ## Soundness
171
189
 
172
190
  The whole point is to **never change observable behavior**.
173
191
 
174
- - **Differential-SSR verified.** Tests server-render the original and the shaken
175
- component (comments stripped, whitespace normalized) and assert the HTML is
176
- identical for every value the app actually passes
177
- (`packages/svelte-shaker/tests/diff.ts`).
178
- - **Conservative bail.** When a transform can't be _proven_ safe, the code is
179
- left as-is. Whole-component bails: `<svelte:options accessors />` /
180
- `customElement`, and any component that **escapes** as a value
181
- (`<svelte:component this={X}>`, assigned/passed/stored), or is rendered through
182
- a barrel/named import (its call sites aren't enumerable). Per-prop bails:
183
- spread that could overwrite it, callee `...rest`, `bind:`, a name shadowed by
184
- `{#each as}` / snippet params / `{#await then}` / `let:` / `{@const}`, or used
185
- in `{@debug}`.
186
- - **Side effects preserved.** A call-site attribute is only stripped if its value
187
- has no side effects; a value's code is removed only when it is provably pure
188
- and unused.
189
- - **Whole-program fixpoint.** Call sites inside a folded-away `{#if}` don't count
190
- toward a child's prop profile; analysis iterates to a fixpoint so cascades are
191
- consistent with what the transform actually deletes.
192
+ - **Differential-SSR verified** tests server-render the original and the
193
+ shaken component and assert the HTML is identical for every value the app
194
+ actually passes.
195
+ - **Conservative bail** — anything unprovable is left as-is. Whole-component:
196
+ `<svelte:options accessors />` / `customElement`, components that escape as a
197
+ value, or are imported through a barrel (call sites not enumerable). Per-prop:
198
+ spread, callee `...rest`, `bind:`, shadowing, `{@debug}`.
199
+ - **Side effects preserved** — an attribute or value is only removed when it is
200
+ provably pure and unused.
201
+ - **Whole-program fixpoint** call sites inside deleted branches don't count
202
+ toward a child's prop profile.
192
203
 
193
204
  ## Limitations
194
205
 
195
- - **Svelte 5 runes only** (`$props()` / `$derived` / `$effect`). Svelte 4
196
- (`export let` / `$:` / `$$props`) is out of scope.
197
- - **Needs `.svelte` source.** Libraries shipping compiled JS can't be shaken (the
198
- source has to be visible — that's the whole premise). Distribute via
199
- `svelte-package`. Anything it can't resolve is silently passed through.
200
- - **Build only.** It runs in `vite build`, not in dev/HMR — whole-program
201
- analysis is fundamentally incompatible with HMR's locality, and L1.5/CSS depend
202
- on negative information ("this value never occurs") that a lazily-loaded dev
203
- server can't guarantee. Dev is always a pass-through (and is unoptimized but
204
- always correct). A `dev: 'coarse'` mode is a future opt-in.
205
- - **`include` must cover the whole app.** A call site outside the scanned dirs is
206
- invisible, so soundness requires every consumer of a prop to be in scope.
207
- - **Partial-bail boundaries.** Spread/rest/`bind:`/shadowing limit how much can be
208
- folded (by design the engine errs toward keeping code). L2's `minSavings`,
209
- and `exclude` / `unsafe` / `report` options, are reserved but not yet
210
- implemented.
211
-
212
- ## Running the tests
213
-
214
- ```sh
215
- pnpm --filter svelte-shaker test # vitest: eval / basic / shadow / probes2 / css / vite / mono
216
- pnpm format:fix && pnpm all:check # type-check + lint + format
217
- ```
218
-
219
- ### Bench
220
-
221
- `packages/svelte-shaker/tests/css.test.ts` builds a tiny app
222
- (`App` passes `variant="primary"` and `variant="secondary"` to `Btn`, whose
223
- `<style>` defines `.btn-{primary,secondary,danger,ghost}`) two ways:
224
-
225
- - **control** (Svelte + Rollup, no shaker): keeps `.btn-danger` and `.btn-ghost`
226
- in the emitted CSS — the toolchain cannot prove them dead.
227
- - **shaken**: removes `.btn-danger` / `.btn-ghost`, keeps `.btn` /
228
- `.btn-primary` / `.btn-secondary`, and the rendered HTML is identical for both
229
- variants the app passes.
230
-
231
- That's the headline result: the same source produces strictly smaller CSS with no
232
- behavior change.
233
-
234
- ## Architecture & status
235
-
236
- The engine is split into an environment-free **Engine** (Svelte-aware analysis +
237
- transform) behind a stable IR, and a thin **Shell** (the Vite/Rollup plugin) that
238
- owns file IO and module resolution — so the core can later be ported to Rust
239
- (rsvelte / OXC). The current implementation status (what's done vs. remaining) is
240
- tracked in [`docs/ARCHITECTURE.md` §11](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md#11-実装状況implementation-status).
206
+ - **Svelte 5 runes only** — Svelte 4 (`export let` / `$:` / `$$props`) is out of
207
+ scope.
208
+ - **Needs `.svelte` source** libraries shipping compiled JS pass through
209
+ unshaken; distribute via `svelte-package`.
210
+ - **Build only** whole-program analysis is incompatible with dev/HMR locality,
211
+ so dev is always a pass-through.
212
+ - **`entries` must cover the whole app** — the crawl starts there, and every
213
+ `.svelte` file it finds is a call-site source. A call site outside those roots
214
+ is invisible, so narrowing `entries` does not shake less, it shakes _wrongly_.
215
+ (Components reached _from_ the roots including library ones in `node_modules`
216
+ are crawled and shaken without being listed.) Call sites in `.ts`/`.js`
217
+ modules under the roots (e.g. `mount(Component, { props })`) are scanned and the
218
+ component's props are kept automatically; a **non-literal** dynamic `import(expr)`
219
+ can't be followed, so reach for `preserve` there. That scan covers modules
220
+ **under the `entries` roots only** a library that mounts its own component
221
+ from its own bundled `.js`/`.ts` inside `node_modules` is not scanned, so list
222
+ it in `preserve` (with its resolved path) if you hit that.
223
+
224
+ See [`docs/ARCHITECTURE.md`](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
225
+ for the full design and implementation status.
241
226
 
242
227
  ## License
243
228
 
package/dist/analyze.d.ts CHANGED
@@ -8,6 +8,25 @@ export type ReadFile = (id: ComponentId) => Promise<string> | string;
8
8
  * {@link buildAnalyzeInputSync}. */
9
9
  export type ResolveSync = (source: string, importer: ComponentId) => ComponentId | null;
10
10
  export type ReadFileSync = (id: ComponentId) => string;
11
+ /**
12
+ * The set of input names a child component can ever OBSERVE at runtime (docs
13
+ * §PR4 reverse analysis). In runes there is no `$$props`/`$$restProps`, so a
14
+ * component reads an input only through its `$props()` destructure:
15
+ * - `{ kind: 'names' }` — a clean, rest-free ObjectPattern `$props()` (or no
16
+ * `$props()` at all, giving the empty set): the child can observe EXACTLY
17
+ * these declared external names, so a call-site input NOT in the set can
18
+ * never be seen and is safe to drop;
19
+ * - `{ kind: 'all' }` — anything we cannot pin down: a `...rest` (captures
20
+ * undeclared inputs), an Identifier/Array binding (`let p = $props()`),
21
+ * more than one `$props()` call, or `$props()` outside a `let <pat> = …`
22
+ * declarator. Then any input might be observed, so nothing is dropped.
23
+ */
24
+ export type ReachableInputs = {
25
+ kind: 'all';
26
+ } | {
27
+ kind: 'names';
28
+ names: Set<string>;
29
+ };
11
30
  /** One declared prop in a `$props()` destructuring. */
12
31
  export interface PropDecl {
13
32
  /** The EXTERNAL prop name — the destructure KEY (`prop` in `prop: alias`).
@@ -47,6 +66,22 @@ export interface FileModel {
47
66
  propsDeclaration?: AnyNode | undefined;
48
67
  propsPattern?: AnyNode | undefined;
49
68
  hasRestProp: boolean;
69
+ /**
70
+ * The inputs this component can observe (docs §PR4). Drives the reverse pass:
71
+ * a call site of THIS component may drop an input outside {@link
72
+ * ReachableInputs}. Computed syntactically from the `$props()` shape.
73
+ */
74
+ reachableInputs: ReachableInputs;
75
+ /**
76
+ * EXTERNAL names of props this component DECLARES but never READS (docs §PR7):
77
+ * destructured out of a clean `$props()` yet with zero value-position reference
78
+ * to their local binding anywhere in the instance script or template. Such a
79
+ * prop is invisible to the child, so its call-site attribute is dead and — when
80
+ * safe — the declaration can be dropped. Source-only (independent of the call
81
+ * sites), so it is computed ONCE here, never inside the fixpoint; the transform
82
+ * gates its use on the component's plan not being bailed.
83
+ */
84
+ unreadDeclaredProps: Set<string>;
50
85
  /**
51
86
  * Every `<Child .../>` instance THIS component renders, with the child it
52
87
  * resolves to and the AST node (so the fixpoint can test whether the site
@@ -69,6 +104,27 @@ export interface FileModel {
69
104
  * dropping the prop dangles the reference — we never fold a prop named here.
70
105
  */
71
106
  debugNames: Set<string>;
107
+ /**
108
+ * Names the component WRITES TO — reassigns (`p = …`, `p += …`), mutates with
109
+ * `++`/`--`, destructure-assigns (`({ p } = obj)`), or two-way `bind:`s. A
110
+ * written prop is not a constant even when every call site passes the same
111
+ * literal: the write changes it at runtime, so folding it would substitute the
112
+ * literal into the write's target (`"a" = …`, `0++`, `bind:value={"a"}`) —
113
+ * invalid Svelte — and, more importantly, silently change what renders after
114
+ * the write. We never fold such a prop, exactly like a shadowed one.
115
+ */
116
+ writtenNames: Set<string>;
117
+ /**
118
+ * Owner-local bindings that are provably a single primitive CONSTANT, keyed by
119
+ * the LOCAL name a forwarded call-site expression references (docs §13.1
120
+ * interprocedural pass-through). Merged into the owner's fold env so that
121
+ * `<Child {count}/>` — where `count` is an unmutated `let count = $state(0)` or
122
+ * a `const count = 0` — folds in the child exactly as a call-site literal would,
123
+ * feeding BOTH constant fold and value-set narrowing. A static property of the
124
+ * source (independent of the fixpoint's plans), so it is computed ONCE here.
125
+ * See {@link computeScriptConstEnv} for the (conservative) admission rules.
126
+ */
127
+ scriptConstEnv: ReadonlyMap<string, Literal>;
72
128
  /**
73
129
  * Resolved ids of CHILD components this file leaks as a value (escape, docs
74
130
  * §4.1) — e.g. `<svelte:component this={Child}>`. `analyze` unions these
@@ -100,6 +156,14 @@ export interface ExplicitProp {
100
156
  value: Literal;
101
157
  dynamic: boolean;
102
158
  afterLastSpread: boolean;
159
+ /**
160
+ * For a `dynamic` write whose value is a single expression (`prop={expr}`, or a
161
+ * known-spread key `{...{prop: expr}}`), the raw expression node — kept so the
162
+ * fixpoint can try to fold it against the OWNING component's constFold env
163
+ * (interprocedural pass-through, docs §13.1). Absent for a literal write, a
164
+ * `bind:` (a two-way write that must never fold), or a multi-part value.
165
+ */
166
+ expr?: AnyNode | undefined;
103
167
  }
104
168
  /** How a child component is called at one `<Child .../>` site. */
105
169
  export interface CallSite {
@@ -112,6 +176,13 @@ export interface CallSite {
112
176
  hadSpread: boolean;
113
177
  /** Last-write-wins explicit props at this site, keyed by prop name. */
114
178
  explicit: Map<string, ExplicitProp>;
179
+ /**
180
+ * The component that OWNS this call site (renders the `<Child .../>`). The
181
+ * fixpoint uses it to evaluate a forwarded expression (`prop={ownerProp}`)
182
+ * against the owner's fold env — interprocedural pass-through (docs §13.1).
183
+ * `undefined` for callers that read a site outside the graph fixpoint (mono).
184
+ */
185
+ owner?: ComponentId | undefined;
115
186
  }
116
187
  export interface AnalyzeResult {
117
188
  models: Map<ComponentId, FileModel>;
@@ -129,7 +200,7 @@ export interface AnalyzeResult {
129
200
  * (b) recomputing plans (and hence dead spans) from that usage,
130
201
  * until the plans stop changing.
131
202
  */
132
- export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile): Promise<AnalyzeResult>;
203
+ export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, escaped?: ComponentId[]): Promise<AnalyzeResult>;
133
204
  /**
134
205
  * The pure, environment-free engine entry (docs/RUST-MIGRATION.md §2): given a
135
206
  * fully-resolved, batched {@link AnalyzeInput}, build every component's model and
@@ -139,6 +210,18 @@ export declare function analyze(entries: ComponentId | ComponentId[], resolve: R
139
210
  * one batched call in, plans out, no per-edge callback across the boundary.
140
211
  */
141
212
  export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCache): AnalyzeResult;
213
+ /**
214
+ * Compute every component's plan to a whole-program fixpoint (docs §2.1) from the
215
+ * models' current `bailReasons` (escape stamps, and — on a revert re-run — the
216
+ * cascade's force-bail stamps). Extracted so the revert cascade can RECOMPUTE the
217
+ * whole fixpoint after force-bailing a component, not just patch that one plan:
218
+ * with interprocedural pass-through (docs §13.1) a child's fold can depend on an
219
+ * owner's fold, so force-bailing the owner must un-fold the child too — an
220
+ * in-place patch of only the owner's plan would leave the child's drop stale
221
+ * (unsound). This mirrors the Rust engine, which re-runs `run_fixpoint` after
222
+ * stamping `forceBail` onto the models.
223
+ */
224
+ export declare function planFixpoint(models: Map<ComponentId, FileModel>): Map<ComponentId, ComponentPlan>;
142
225
  /**
143
226
  * The Shell-side resolution + IO layer (docs/RUST-MIGRATION.md §2.1): BFS-crawl
144
227
  * the component graph from `entries`, resolving every import edge and reading
@@ -152,14 +235,14 @@ export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCach
152
235
  * import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
153
236
  * value set), keeping the produced model set — and thus the output — identical.
154
237
  */
155
- export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse): Promise<AnalyzeInput>;
238
+ export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): Promise<AnalyzeInput>;
156
239
  /**
157
240
  * Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
158
241
  * (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
159
242
  * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
160
243
  * pins it identical to the async path, so keep the two bodies in lockstep.
161
244
  */
162
- export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse): AnalyzeInput;
245
+ export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): AnalyzeInput;
163
246
  /**
164
247
  * Dead `{#if}` spans per component implied by `plans`, via the SAME shared
165
248
  * predicate the transform uses ({@link computeDeadSpans}). A bailed component
@@ -190,11 +273,16 @@ export interface UnpassedProp {
190
273
  * `+page.svelte`'s `data`, a framework mount, a not-yet-rendered component);
191
274
  * - a prop is reported only when EVERY call site neither names it nor carries a
192
275
  * spread that could set it (`readCallSite` already folds `bind:`, known
193
- * spreads, and `children`/snippet body into `explicit`/`hadSpread`).
276
+ * spreads, and `children`/snippet body into `explicit`/`hadSpread`);
277
+ * - a component in `input.escaped` — one the Shell knows has a consumer OUTSIDE
278
+ * the `.svelte` graph (a `.ts`/`.js` call site, or a user `preserve`, docs
279
+ * §4.2) — is skipped, because that consumer may pass a prop the crawl cannot see.
194
280
  *
195
- * Because missing an edge (e.g. an unfollowed barrel) only DROPS call sites, it
196
- * can only make this UNDER-report (the component looks unused and is skipped),
197
- * never over-report so an incomplete crawl stays false-positive-free.
281
+ * Missing a `.svelte` EDGE (e.g. an unfollowed barrel) only DROPS call sites, so it
282
+ * can only make this UNDER-report (the component looks unused and is skipped). The
283
+ * one way it could OVER-report is a consumer the crawl cannot parse at all — a
284
+ * `.ts`/`.js` call site; `input.escaped` (the Shell's non-`.svelte` scan) closes
285
+ * exactly that hole, so with it supplied the result stays false-positive-free.
198
286
  */
199
287
  export declare function findNeverPassedProps(input: AnalyzeInput): Map<ComponentId, UnpassedProp[]>;
200
288
  /**
@@ -218,7 +306,7 @@ export declare function remapToLocalNames<V>(map: Map<string, V>, model: FileMod
218
306
  * both contributes those literals AND does not poison props it cannot set (docs
219
307
  * §4.1, "{...obj} が object literal ならキー展開").
220
308
  */
221
- export declare function readCallSite(component: AnyNode): CallSite;
309
+ export declare function readCallSite(component: AnyNode, owner?: ComponentId): CallSite;
222
310
  /** Decide what to fold for one component from its global usage. */
223
311
  /**
224
312
  * Whether a declared prop name is unsafe to fold/narrow/drop because it is also
@@ -226,7 +314,9 @@ export declare function readCallSite(component: AnyNode): CallSite;
226
314
  * (`{#each as}`, snippet params, `{#await then}`, `let:`, `{@const}`), or used as
227
315
  * a `{@debug}` argument (Svelte forbids a literal there). In those scopes the
228
316
  * name is a different entity, so folding it would corrupt the binding (often
229
- * invalid Svelte). Both L1 planning ({@link buildPlan}) and L2 specialization
230
- * (mono.ts) must honor this identically.
317
+ * invalid Svelte) or WRITTEN TO (reassigned / `++` / destructure-assigned /
318
+ * `bind:`), in which case it is not a constant and folding it changes what
319
+ * renders after the write. Both constant fold planning ({@link buildPlan}) and
320
+ * monomorphization specialization (mono.ts) must honor this identically.
231
321
  */
232
322
  export declare function isFoldBlockedName(model: FileModel, name: string): boolean;