svelte-shaker 0.11.0 → 0.13.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
@@ -91,153 +75,134 @@ export default defineConfig({
91
75
  });
92
76
  ```
93
77
 
94
- There is also a plain-Rollup plugin (`rollup-plugin-svelte-shaker`) for non-Vite
95
- pipelines; the Vite plugin is preferred for apps.
78
+ For plain-Rollup pipelines, wire the shake up yourself with the public engine
79
+ API (`svelte-shaker`) and the file-system helpers in `svelte-shaker/node`. Note
80
+ that monomorphization additionally needs the `?shaker_variant` requests routed
81
+ through your plugin's `resolveId`/`load` hooks; the unused-prop fold / constant
82
+ fold / value-set narrowing shake only needs the `transform` swap. The
83
+ environment-free engine and the in-browser playground parse with svelte/compiler
84
+ — the rsvelte default is a Vite-plugin concern (it loads a Node-only WASM
85
+ module); the engine takes an optional `parse` argument if you want to swap it.
96
86
 
97
87
  ### Options
98
88
 
99
89
  ```ts
100
90
  shaker({
101
91
  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
+ external: [], // components to freezenever fold their props (see below)
93
+ monomorphize: true, // default on; `false` disables it for faster builds,
94
+ // or { maxVariants: 16, minSavings: 0.05 } to tune
95
+ verbose: false, // true = per-file size breakdown after the build
96
+
97
+ // Escape hatches — the defaults ARE the Rust path; set these only to opt
98
+ // out (e.g. if you ever hit a bug in it).
99
+ engine: 'auto', // 'auto' (default: Rust/WASM, else JS) | 'js' | 'rust'
100
+ parser: 'rsvelte', // 'rsvelte' (default) | 'svelte' (fallback)
106
101
  });
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
102
  ```
143
103
 
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) |
104
+ - **The defaults are the Rust path.** Out of the box svelte-shaker runs the
105
+ native **Rust (WASM) engine** and parses with **rsvelte**, loaded from
106
+ [`@rsvelte/compiler`](https://github.com/baseballyama/rsvelte) (a bundled WASM
107
+ dependency nothing to install). The Rust (WASM) engine is differentially
108
+ tested to shake **byte-identically** to the JS engine. The parser choice is
109
+ **soundness-neutral**: the engine reads only UTF-16 `start`/`end`, so
110
+ svelte/compiler and rsvelte are differentially tested to produce
111
+ **SSR-equivalent** output the choice never changes what renders. rsvelte is
112
+ the default as the Rust parser the pipeline is standardizing on (end-to-end
113
+ Rust with the WASM engine); `parser: 'svelte'` stays available as the
114
+ fallback.
115
+ - **`monomorphize`** — the one shaking knob, **on** by default. A measured
116
+ net-win gate only specializes a component when that strictly shrinks the whole
117
+ program, so monomorphization **never bloats**: whatever the knobs are set to,
118
+ a build with `monomorphize` on is never larger, byte for byte, than the same
119
+ build with it off (the 3 always-on passes alone). The knobs only trade off how
120
+ much specialization is *attempted* against build time:
121
+ - `maxVariants` (default `8`) cap on distinct residual variants per
122
+ component. A child whose call sites produce more distinct shapes than the
123
+ cap can't be specialized at every site, so it keeps its base entirely
124
+ (all-sites-or-nothing no partial split). Raise it for a large
125
+ design-system component (e.g. a `Button` used with more than 8 prop shapes
126
+ app-wide) you know is worth specializing further.
127
+ - `minSavings` (default `0`, i.e. any strict net reduction) — the net-win
128
+ threshold: a specialization is applied only when it measures
129
+ `Σ_spec < Σ_base × (1 − minSavings)`. Raising it only makes the gate more
130
+ conservative (fewer, bigger wins, faster builds) — no value makes
131
+ monomorphization unsound.
132
+
133
+ ```ts
134
+ monomorphize: { maxVariants: 16, minSavings: 0.05 } // e.g. a variant-heavy
135
+ // design system, while skipping specializations that save under 5%
136
+ ```
137
+ - **Escape hatches (`engine` / `parser`).** If you ever hit a bug in the Rust
138
+ path, opt out per axis: `engine: 'js'` forces the JS engine, `parser: 'svelte'`
139
+ forces svelte/compiler (the previous default). `@rsvelte/compiler` is a bundled
140
+ dependency, so the default parser normally just loads; in the unlikely event it
141
+ can't (a broken install), the plugin **throws** rather than silently falling
142
+ back — so the same source always shakes the same on every machine. Reinstall
143
+ dependencies, or set `parser: 'svelte'`.
144
+ - **`external`** — freeze components a `.ts`/`.js` module uses in a way the shake
145
+ can't see, so their props are never folded. The plugin already scans your
146
+ non-`.svelte` modules and auto-freezes any component reached by a static import,
147
+ `export … from`, or a **literal** `import('./X.svelte')` — so a plain
148
+ `mount(Component, { props })` is handled for you. Use `external` for what the
149
+ scan can't follow: a **non-literal** dynamic `import(expr)`, or a call site in a
150
+ module outside `include`. Entries are root-relative or absolute paths naming a
151
+ component file (with its `.svelte` extension) or a directory of them (same basis
152
+ as `include`). It **freezes** the component — the file stays fully analyzed and
153
+ its own call sites still count; only its own prop folding is turned off. It is not
154
+ a scan-exclusion filter. The build **warns** (with the file path) about any module
155
+ the scan couldn't parse — so a mounted component isn't silently left unprotected —
156
+ and about `external` entries that matched no component.
157
+
158
+ ## What it removes
159
+
160
+ | Pass | What it removes | Default |
161
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
162
+ | **unused-prop fold** | Props no call site ever passes → fold to the default, drop from `$props()`, strip the attribute at call sites | on |
163
+ | **constant fold** | Props that collapse to one constant app-wide → fold + drop + strip every call site's attribute | on |
164
+ | **value-set narrowing** | With `variant ∈ {primary, secondary}`, delete provably-dead `{#if}`/`{:else if}` arms (prop stays in the signature) | on |
165
+ | **CSS** | `<style>` rules whose class can never be produced given the value sets | on |
166
+ | **monomorphization** | Per-call-site: specialize a component per prop shape (deduped by residual, capped by `maxVariants`) | on (`monomorphize: false` to disable) |
166
167
 
167
168
  Folding also reaches template ternaries (`{cond ? a : b}`) and class-string
168
- interpolation when the condition/parts are provable constants.
169
+ interpolation when the parts are provable constants.
169
170
 
170
171
  ## Soundness
171
172
 
172
173
  The whole point is to **never change observable behavior**.
173
174
 
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.
175
+ - **Differential-SSR verified** tests server-render the original and the
176
+ shaken component and assert the HTML is identical for every value the app
177
+ actually passes.
178
+ - **Conservative bail** — anything unprovable is left as-is. Whole-component:
179
+ `<svelte:options accessors />` / `customElement`, components that escape as a
180
+ value, or are imported through a barrel (call sites not enumerable). Per-prop:
181
+ spread, callee `...rest`, `bind:`, shadowing, `{@debug}`.
182
+ - **Side effects preserved** — an attribute or value is only removed when it is
183
+ provably pure and unused.
184
+ - **Whole-program fixpoint** call sites inside deleted branches don't count
185
+ toward a child's prop profile.
192
186
 
193
187
  ## Limitations
194
188
 
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).
189
+ - **Svelte 5 runes only** — Svelte 4 (`export let` / `$:` / `$$props`) is out of
190
+ scope.
191
+ - **Needs `.svelte` source** libraries shipping compiled JS pass through
192
+ unshaken; distribute via `svelte-package`.
193
+ - **Build only** whole-program analysis is incompatible with dev/HMR locality,
194
+ so dev is always a pass-through.
195
+ - **`include` must cover the whole app** a call site outside the scanned dirs
196
+ is invisible, which would make prop elimination unsound. Call sites in
197
+ `.ts`/`.js` modules under `include` (e.g. `mount(Component, { props })`) are
198
+ scanned and the component is frozen automatically; a **non-literal** dynamic
199
+ `import(expr)` can't be followed, so reach for `external` there. The scan covers
200
+ modules **under `include`** a library that mounts its own component from its own
201
+ bundled `.js`/`.ts` inside `node_modules` is not scanned, so freeze it via
202
+ `external` (with its resolved path) if you hit that.
203
+
204
+ See [`docs/ARCHITECTURE.md`](https://github.com/baseballyama/svelte-shaker/blob/main/docs/ARCHITECTURE.md)
205
+ for the full design and implementation status.
241
206
 
242
207
  ## License
243
208
 
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,16 @@ 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>;
72
117
  /**
73
118
  * Resolved ids of CHILD components this file leaks as a value (escape, docs
74
119
  * §4.1) — e.g. `<svelte:component this={Child}>`. `analyze` unions these
@@ -100,6 +145,14 @@ export interface ExplicitProp {
100
145
  value: Literal;
101
146
  dynamic: boolean;
102
147
  afterLastSpread: boolean;
148
+ /**
149
+ * For a `dynamic` write whose value is a single expression (`prop={expr}`, or a
150
+ * known-spread key `{...{prop: expr}}`), the raw expression node — kept so the
151
+ * fixpoint can try to fold it against the OWNING component's constFold env
152
+ * (interprocedural pass-through, docs §13.1). Absent for a literal write, a
153
+ * `bind:` (a two-way write that must never fold), or a multi-part value.
154
+ */
155
+ expr?: AnyNode | undefined;
103
156
  }
104
157
  /** How a child component is called at one `<Child .../>` site. */
105
158
  export interface CallSite {
@@ -112,6 +165,13 @@ export interface CallSite {
112
165
  hadSpread: boolean;
113
166
  /** Last-write-wins explicit props at this site, keyed by prop name. */
114
167
  explicit: Map<string, ExplicitProp>;
168
+ /**
169
+ * The component that OWNS this call site (renders the `<Child .../>`). The
170
+ * fixpoint uses it to evaluate a forwarded expression (`prop={ownerProp}`)
171
+ * against the owner's fold env — interprocedural pass-through (docs §13.1).
172
+ * `undefined` for callers that read a site outside the graph fixpoint (mono).
173
+ */
174
+ owner?: ComponentId | undefined;
115
175
  }
116
176
  export interface AnalyzeResult {
117
177
  models: Map<ComponentId, FileModel>;
@@ -129,7 +189,7 @@ export interface AnalyzeResult {
129
189
  * (b) recomputing plans (and hence dead spans) from that usage,
130
190
  * until the plans stop changing.
131
191
  */
132
- export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile): Promise<AnalyzeResult>;
192
+ export declare function analyze(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, escaped?: ComponentId[]): Promise<AnalyzeResult>;
133
193
  /**
134
194
  * The pure, environment-free engine entry (docs/RUST-MIGRATION.md §2): given a
135
195
  * fully-resolved, batched {@link AnalyzeInput}, build every component's model and
@@ -139,6 +199,18 @@ export declare function analyze(entries: ComponentId | ComponentId[], resolve: R
139
199
  * one batched call in, plans out, no per-edge callback across the boundary.
140
200
  */
141
201
  export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCache): AnalyzeResult;
202
+ /**
203
+ * Compute every component's plan to a whole-program fixpoint (docs §2.1) from the
204
+ * models' current `bailReasons` (escape stamps, and — on a revert re-run — the
205
+ * cascade's force-bail stamps). Extracted so the revert cascade can RECOMPUTE the
206
+ * whole fixpoint after force-bailing a component, not just patch that one plan:
207
+ * with interprocedural pass-through (docs §13.1) a child's fold can depend on an
208
+ * owner's fold, so force-bailing the owner must un-fold the child too — an
209
+ * in-place patch of only the owner's plan would leave the child's drop stale
210
+ * (unsound). This mirrors the Rust engine, which re-runs `run_fixpoint` after
211
+ * stamping `forceBail` onto the models.
212
+ */
213
+ export declare function planFixpoint(models: Map<ComponentId, FileModel>): Map<ComponentId, ComponentPlan>;
142
214
  /**
143
215
  * The Shell-side resolution + IO layer (docs/RUST-MIGRATION.md §2.1): BFS-crawl
144
216
  * the component graph from `entries`, resolving every import edge and reading
@@ -152,14 +224,14 @@ export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCach
152
224
  * import is never crawled — its `<Comp/>` site cannot exist, so it cannot taint a
153
225
  * value set), keeping the produced model set — and thus the output — identical.
154
226
  */
155
- export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse): Promise<AnalyzeInput>;
227
+ export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): Promise<AnalyzeInput>;
156
228
  /**
157
229
  * Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
158
230
  * (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
159
231
  * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
160
232
  * pins it identical to the async path, so keep the two bodies in lockstep.
161
233
  */
162
- export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse): AnalyzeInput;
234
+ export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse, escaped?: ComponentId[]): AnalyzeInput;
163
235
  /**
164
236
  * Dead `{#if}` spans per component implied by `plans`, via the SAME shared
165
237
  * predicate the transform uses ({@link computeDeadSpans}). A bailed component
@@ -190,11 +262,16 @@ export interface UnpassedProp {
190
262
  * `+page.svelte`'s `data`, a framework mount, a not-yet-rendered component);
191
263
  * - a prop is reported only when EVERY call site neither names it nor carries a
192
264
  * spread that could set it (`readCallSite` already folds `bind:`, known
193
- * spreads, and `children`/snippet body into `explicit`/`hadSpread`).
265
+ * spreads, and `children`/snippet body into `explicit`/`hadSpread`);
266
+ * - a component in `input.escaped` — one the Shell knows has a consumer OUTSIDE
267
+ * the `.svelte` graph (a `.ts`/`.js` call site, or a user `external`, docs
268
+ * §4.2) — is skipped, because that consumer may pass a prop the crawl cannot see.
194
269
  *
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.
270
+ * Missing a `.svelte` EDGE (e.g. an unfollowed barrel) only DROPS call sites, so it
271
+ * can only make this UNDER-report (the component looks unused and is skipped). The
272
+ * one way it could OVER-report is a consumer the crawl cannot parse at all — a
273
+ * `.ts`/`.js` call site; `input.escaped` (the Shell's non-`.svelte` scan) closes
274
+ * exactly that hole, so with it supplied the result stays false-positive-free.
198
275
  */
199
276
  export declare function findNeverPassedProps(input: AnalyzeInput): Map<ComponentId, UnpassedProp[]>;
200
277
  /**
@@ -218,7 +295,7 @@ export declare function remapToLocalNames<V>(map: Map<string, V>, model: FileMod
218
295
  * both contributes those literals AND does not poison props it cannot set (docs
219
296
  * §4.1, "{...obj} が object literal ならキー展開").
220
297
  */
221
- export declare function readCallSite(component: AnyNode): CallSite;
298
+ export declare function readCallSite(component: AnyNode, owner?: ComponentId): CallSite;
222
299
  /** Decide what to fold for one component from its global usage. */
223
300
  /**
224
301
  * Whether a declared prop name is unsafe to fold/narrow/drop because it is also
@@ -226,7 +303,9 @@ export declare function readCallSite(component: AnyNode): CallSite;
226
303
  * (`{#each as}`, snippet params, `{#await then}`, `let:`, `{@const}`), or used as
227
304
  * a `{@debug}` argument (Svelte forbids a literal there). In those scopes the
228
305
  * 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.
306
+ * invalid Svelte) or WRITTEN TO (reassigned / `++` / destructure-assigned /
307
+ * `bind:`), in which case it is not a constant and folding it changes what
308
+ * renders after the write. Both constant fold planning ({@link buildPlan}) and
309
+ * monomorphization specialization (mono.ts) must honor this identically.
231
310
  */
232
311
  export declare function isFoldBlockedName(model: FileModel, name: string): boolean;