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.
@@ -1,6 +1,6 @@
1
1
  import type MagicString from 'magic-string';
2
- import type { ComponentPlan } from './ir';
3
- import type { FileModel } from './analyze';
2
+ import type { ComponentPlan } from './ir.js';
3
+ import type { FileModel } from './analyze.js';
4
4
  /**
5
5
  * The set of class names any element this component renders could carry.
6
6
  * `unbounded` means at least one element has a class source we cannot enumerate
package/dist/css.js ADDED
@@ -0,0 +1,256 @@
1
+ // ----------------------------------------------------------------------
2
+ // CSS rule removal (docs/ARCHITECTURE.md §3 "L1.5", "CSS (shaker 独自の価値)").
3
+ //
4
+ // The headline differentiator: drop `<style>` rules that target a class the
5
+ // component can PROVABLY never produce, given the value sets we computed.
6
+ // Svelte's own unused-CSS pruning keeps `.btn-danger` for an interpolated
7
+ // `class="btn btn-{variant}"` because it cannot see that `variant ∈
8
+ // {primary,secondary}`; rollup cannot either (the class only exists at runtime).
9
+ // We can, because we know the reachable value set of `variant`.
10
+ //
11
+ // SOUNDNESS (the whole contract): a rule is removed ONLY when the component's
12
+ // set of possible class names is BOUNDED (every class source enumerable) and the
13
+ // rule's every selector references a class that is NOT in that bounded set and
14
+ // the rule has no `:global(...)`. A removed rule therefore targets a class no
15
+ // element can ever carry, so it could never have matched any element this
16
+ // component renders — removing it cannot change the rendered styling of any
17
+ // element that can actually occur. When anything is uncertain, we KEEP the rule.
18
+ // ----------------------------------------------------------------------
19
+ import { walk } from './parse.js';
20
+ import { evaluate } from './eval.js';
21
+ /** Cap on the cartesian product of interpolated parts; over it -> unbounded. */
22
+ const MAX_CLASS_COMBOS = 64;
23
+ /**
24
+ * Compute the component's possible class set (docs §3, step 1). Sources:
25
+ * - static `class="a b"` -> the literal tokens,
26
+ * - `class:foo` directive -> `foo` is always possible (regardless of its cond),
27
+ * - `class="x-{expr}"` / `class={expr}` where every interpolated `expr` is
28
+ * foldable (constFold) or narrowable (narrow set) -> enumerate the tokens,
29
+ * - ANY unbounded source (`class={nonFoldable}`, or a `{...spread}` that could
30
+ * carry `class`) -> the whole set is unbounded.
31
+ */
32
+ export function computePossibleClasses(model, plan) {
33
+ const classes = new Set();
34
+ let unbounded = false;
35
+ const env = plan.constFold;
36
+ const setEnv = plan.narrow;
37
+ walk(model.ast.fragment, null, {
38
+ _(node, { next }) {
39
+ if (!isElementLike(node.type)) {
40
+ next();
41
+ return;
42
+ }
43
+ for (const attr of node.attributes ?? []) {
44
+ // Any spread could carry a `class` -> we can no longer enumerate.
45
+ if (attr.type === 'SpreadAttribute') {
46
+ unbounded = true;
47
+ continue;
48
+ }
49
+ if (attr.type === 'ClassDirective') {
50
+ // `class:foo={cond}` toggles `foo`; `foo` is always a possible class.
51
+ if (attr.name)
52
+ classes.add(attr.name);
53
+ continue;
54
+ }
55
+ if (attr.type !== 'Attribute' || attr.name !== 'class')
56
+ continue;
57
+ const result = classTokensFromAttr(attr.value, env, setEnv);
58
+ if (result === UNBOUNDED)
59
+ unbounded = true;
60
+ else
61
+ for (const c of result)
62
+ classes.add(c);
63
+ }
64
+ next();
65
+ },
66
+ });
67
+ return { classes, unbounded };
68
+ }
69
+ /** Sentinel: this class source cannot be enumerated. */
70
+ const UNBOUNDED = Symbol('unbounded-class-source');
71
+ /**
72
+ * Class tokens contributed by one `class=` attribute value, or {@link UNBOUNDED}
73
+ * if any interpolated part is not statically enumerable. Builds the cartesian
74
+ * product of each part's possible strings, then splits each candidate on
75
+ * whitespace into individual class tokens.
76
+ */
77
+ function classTokensFromAttr(value, env, setEnv) {
78
+ // `class` with `value === true` is the `{class}` shorthand (i.e. `class={class}`)
79
+ // — a dynamic binding we cannot enumerate.
80
+ if (value === true)
81
+ return UNBOUNDED;
82
+ if (value == null)
83
+ return new Set();
84
+ const parts = (Array.isArray(value) ? value : [value]);
85
+ // Each part yields a set of possible string fragments; the attribute's
86
+ // possible full strings are the cartesian product (concatenated in order).
87
+ let combos = [''];
88
+ for (const part of parts) {
89
+ const frags = partStrings(part, env, setEnv);
90
+ if (frags === UNBOUNDED)
91
+ return UNBOUNDED;
92
+ const nextCombos = [];
93
+ for (const base of combos)
94
+ for (const f of frags) {
95
+ nextCombos.push(base + f);
96
+ if (nextCombos.length > MAX_CLASS_COMBOS)
97
+ return UNBOUNDED;
98
+ }
99
+ combos = nextCombos;
100
+ }
101
+ const tokens = new Set();
102
+ for (const combo of combos)
103
+ for (const tok of combo.split(/\s+/))
104
+ if (tok)
105
+ tokens.add(tok);
106
+ return tokens;
107
+ }
108
+ /** The possible string values of one attribute part (Text or ExpressionTag). */
109
+ function partStrings(part, env, setEnv) {
110
+ if (part.type === 'Text') {
111
+ return new Set([(part.data ?? part.raw ?? '')]);
112
+ }
113
+ if (part.type === 'ExpressionTag') {
114
+ return expressionStrings(part.expression, env, setEnv);
115
+ }
116
+ // Unknown part kind (e.g. MustacheTag in older trees): be conservative.
117
+ return UNBOUNDED;
118
+ }
119
+ /**
120
+ * The possible string values of an interpolated `{expr}` in a class attribute.
121
+ * A bare set-var enumerates its reachable literals; anything else must be a
122
+ * provable constant (constFold / literal). Non-string literals are stringified
123
+ * the way the DOM would (`String(value)`); anything unprovable is {@link
124
+ * UNBOUNDED}.
125
+ */
126
+ function expressionStrings(expr, env, setEnv) {
127
+ if (!expr)
128
+ return UNBOUNDED;
129
+ // A narrowable prop used directly: enumerate its reachable value set.
130
+ if (expr.type === 'Identifier' && expr.name && setEnv.has(expr.name)) {
131
+ const out = new Set();
132
+ for (const v of setEnv.get(expr.name))
133
+ out.add(stringifyLiteral(v));
134
+ return out;
135
+ }
136
+ // Otherwise it must fold to a single constant (constFold prop or literal expr).
137
+ // We reuse the same sound evaluator the branch folding uses.
138
+ const folded = evaluate(expr, env);
139
+ if (folded.known)
140
+ return new Set([stringifyLiteral(folded.value)]);
141
+ return UNBOUNDED;
142
+ }
143
+ /** How a class fragment renders into the DOM `class` attribute string. */
144
+ function stringifyLiteral(v) {
145
+ // `null`/`undefined`/`false` interpolated into a string become "null"/"undefined"/"false".
146
+ // Svelte concatenates the template, so this matches `String(v)`.
147
+ return String(v);
148
+ }
149
+ function isElementLike(type) {
150
+ // Components can also forward a `class` to a root element, and `<svelte:element>`
151
+ // is dynamic markup — treat all of these as class-bearing for soundness.
152
+ return (type === 'RegularElement' ||
153
+ type === 'SvelteElement' ||
154
+ type === 'Component' ||
155
+ type === 'SvelteComponent' ||
156
+ type === 'SvelteSelf');
157
+ }
158
+ // ----------------------------------------------------------------------
159
+ // Rule removal
160
+ // ----------------------------------------------------------------------
161
+ /**
162
+ * Remove provably-dead top-level `<style>` rules via MagicString span removal
163
+ * (docs §3, step 2/3). A rule is removed ONLY when the possible class set is
164
+ * bounded, the rule contains no `:global(...)` anywhere, and EVERY selector in
165
+ * the rule's selector list contains at least one class `.C` with `C` absent from
166
+ * the possible set (so no selector in the list can match any element this
167
+ * component can render). Anything else is KEPT. Returns the number removed.
168
+ */
169
+ export function shakeCss(model, plan, s) {
170
+ const css = model.ast.css;
171
+ if (!css || !css.children)
172
+ return 0;
173
+ const possible = computePossibleClasses(model, plan);
174
+ // If we cannot bound the class set, every interpolated class might exist:
175
+ // removing nothing is the only sound choice.
176
+ if (possible.unbounded)
177
+ return 0;
178
+ let removed = 0;
179
+ for (const child of css.children) {
180
+ // Only top-level Rules are considered. Atrules (`@media`, `@keyframes`, …)
181
+ // are left entirely to Svelte's own pruning: removing a `@media` because one
182
+ // inner rule is dead would be unsound, and keyframes never reference classes.
183
+ if (child.type !== 'Rule')
184
+ continue;
185
+ if (isRuleDead(child, possible.classes)) {
186
+ removeRule(model.code, child, css.children, s);
187
+ removed += 1;
188
+ }
189
+ }
190
+ return removed;
191
+ }
192
+ /**
193
+ * Is this whole rule provably dead given the bounded possible class set?
194
+ *
195
+ * A rule is dead iff it contains NO `:global(...)` anywhere AND every selector in
196
+ * its selector list is dead — where a selector (ComplexSelector) is dead iff at
197
+ * least one class `.C` it requires is NOT in the possible set. Requiring a
198
+ * provably-absent class anywhere in the compound/combinator chain means the whole
199
+ * complex selector can never match (every class in the chain must be present on
200
+ * some element for it to match). If a complex selector references no class at
201
+ * all (pure element/id/attribute/pseudo), it is NOT dead — we never remove such
202
+ * rules. An empty selector list is treated as not-dead (keep).
203
+ */
204
+ function isRuleDead(rule, possible) {
205
+ if (hasGlobal(rule))
206
+ return false;
207
+ const complexes = rule.prelude?.children ?? [];
208
+ if (complexes.length === 0)
209
+ return false;
210
+ return complexes.every((complex) => isComplexDead(complex, possible));
211
+ }
212
+ /**
213
+ * A ComplexSelector is dead iff it requires (via a `ClassSelector`) at least one
214
+ * class that the component can never produce. Every class named in the chain
215
+ * must be present on a matching element, so a single absent required class makes
216
+ * the whole selector unmatchable.
217
+ */
218
+ function isComplexDead(complex, possible) {
219
+ let dead = false;
220
+ for (const rel of complex.children ?? []) {
221
+ for (const sel of rel.selectors ?? []) {
222
+ if (sel.type === 'ClassSelector' && sel.name && !possible.has(sel.name)) {
223
+ dead = true;
224
+ }
225
+ }
226
+ }
227
+ return dead;
228
+ }
229
+ /** Does any selector in this rule use `:global(...)`? If so we never touch it. */
230
+ function hasGlobal(rule) {
231
+ let found = false;
232
+ walk(rule, null, {
233
+ _(node, { next }) {
234
+ if (node.type === 'PseudoClassSelector' && node.name === 'global')
235
+ found = true;
236
+ next();
237
+ },
238
+ });
239
+ return found;
240
+ }
241
+ /**
242
+ * Remove a rule's source span, eating the run of whitespace before it so the
243
+ * `<style>` body stays tidy and Svelte still parses what remains.
244
+ */
245
+ function removeRule(code, rule, siblings, s) {
246
+ const i = siblings.indexOf(rule);
247
+ const prev = siblings[i - 1];
248
+ // Start just after the previous sibling (or after the leading whitespace at
249
+ // the top of the stylesheet); end at the rule's end. This removes the rule
250
+ // and the blank line/indentation that preceded it.
251
+ let start = rule.start;
252
+ const floor = prev ? prev.end : 0;
253
+ while (start > floor && /\s/.test(code[start - 1]))
254
+ start -= 1;
255
+ s.remove(start, rule.end);
256
+ }
@@ -1,5 +1,5 @@
1
- import { type AnyNode } from './parse';
2
- import type { Literal } from './ir';
1
+ import { type AnyNode } from './parse.js';
2
+ import type { Literal } from './ir.js';
3
3
  export type Span = [number, number];
4
4
  /** One arm of an `{#if}` / `{:else if}` chain in source order. */
5
5
  interface ChainArm {
package/dist/dead.js ADDED
@@ -0,0 +1,195 @@
1
+ // ----------------------------------------------------------------------
2
+ // Shared `{#if}`-folding predicate (docs/ARCHITECTURE.md §3, §13).
3
+ //
4
+ // Both the transform (which *edits* the source) and the analysis fixpoint
5
+ // (which needs to know *which call sites disappear*) must agree, to the byte,
6
+ // on what folds away — otherwise the fixpoint could exclude a call site the
7
+ // transform actually keeps (unsound) or keep one the transform removes (misses
8
+ // a cascade). So the decision for a single if/else-if chain lives here, once,
9
+ // and is consumed by both:
10
+ // - transform.ts turns a `ChainDecision` into MagicString edits.
11
+ // - computeDeadSpans() turns the same decisions into the spans of source that
12
+ // genuinely vanish from the output (used to drop call sites in dead code).
13
+ // ----------------------------------------------------------------------
14
+ import { walk } from './parse.js';
15
+ import { evaluateWithSets } from './eval.js';
16
+ /**
17
+ * Collect the arms of an `{#if}` / `{:else if}` chain in source order, plus the
18
+ * trailing `{:else}` fragment if present. Each arm carries its own IfBlock node
19
+ * (the head, or an `elseif` continuation).
20
+ */
21
+ export function collectChain(top) {
22
+ const arms = [];
23
+ let cur = top;
24
+ let elseFrag;
25
+ while (cur) {
26
+ arms.push({ block: cur, test: cur.test, consequent: cur.consequent });
27
+ const alt = cur.alternate;
28
+ // `{:else if}` is encoded as an alternate Fragment whose only node is an
29
+ // IfBlock with `elseif === true`; a plain `{:else}` is any other Fragment.
30
+ const elseif = alt?.type === 'Fragment' &&
31
+ alt.nodes?.length === 1 &&
32
+ alt.nodes[0]?.type === 'IfBlock' &&
33
+ alt.nodes[0].elseif === true
34
+ ? alt.nodes[0]
35
+ : undefined;
36
+ if (elseif) {
37
+ cur = elseif;
38
+ }
39
+ else {
40
+ if (alt?.type === 'Fragment')
41
+ elseFrag = alt;
42
+ cur = undefined;
43
+ }
44
+ }
45
+ return elseFrag ? { arms, elseFrag } : { arms };
46
+ }
47
+ /**
48
+ * Decide how one if/else-if chain folds against `env` (constFold) and `setEnv`
49
+ * (value sets). Soundness: an arm is dropped only when its test is provably
50
+ * FALSE for every reachable value; the chain collapses to a consequent only when
51
+ * an arm is provably TRUE and every earlier arm is provably FALSE. Otherwise
52
+ * the head is kept and callers recurse for nested blocks.
53
+ *
54
+ * This is the single source of truth used by BOTH the transform and the
55
+ * analysis fixpoint, so they can never disagree on what folds.
56
+ */
57
+ export function decideChain(top, env, setEnv) {
58
+ const { arms, elseFrag } = collectChain(top);
59
+ const span = [top.start, top.end];
60
+ // An `{#if}` test fires on truthiness; a known result (const literal OR a
61
+ // proven set-boolean) settles the arm, anything else leaves it live.
62
+ const truth = arms.map((a) => evaluateWithSets(a.test, env, setEnv));
63
+ const isTrue = (t) => t.known && Boolean(t.value);
64
+ const isFalse = (t) => t.known && !t.value;
65
+ // (a) An arm is provably TRUE and every earlier arm is provably FALSE -> that
66
+ // arm is always taken: collapse the whole chain to its consequent.
67
+ let allEarlierFalse = true;
68
+ for (let i = 0; i < arms.length; i++) {
69
+ const t = truth[i];
70
+ if (isTrue(t) && allEarlierFalse) {
71
+ const consequent = arms[i].consequent;
72
+ return {
73
+ span,
74
+ kept: consequent,
75
+ removed: aroundKept(span, fragmentSpan(consequent)),
76
+ recurse: false, // re-emitted verbatim; transform does not re-walk it
77
+ };
78
+ }
79
+ if (!isFalse(t))
80
+ allEarlierFalse = false;
81
+ }
82
+ // (b) Otherwise keep the arms that are not provably false.
83
+ const firstKept = truth.findIndex((t) => !isFalse(t));
84
+ if (firstKept === -1) {
85
+ // Every arm is provably false: only the `{:else}` (if any) can render.
86
+ if (elseFrag) {
87
+ return {
88
+ span,
89
+ kept: elseFrag,
90
+ removed: aroundKept(span, fragmentSpan(elseFrag)),
91
+ recurse: false,
92
+ };
93
+ }
94
+ return { span, kept: undefined, removed: [span], recurse: false };
95
+ }
96
+ if (firstKept === 0) {
97
+ // Head survives in place: only later provably-false arms are removed, and
98
+ // nested `{#if}` inside the kept arms must still be folded -> recurse.
99
+ return {
100
+ span,
101
+ kept: undefined,
102
+ removed: deadTail(arms, truth, firstKept),
103
+ recurse: true,
104
+ };
105
+ }
106
+ // Head (and maybe more) is provably false: delete the dead prefix and promote
107
+ // the first surviving arm from `{:else if …}` to `{#if …}`. The promoted arm
108
+ // is re-emitted verbatim, so we do not recurse into it.
109
+ const kept = arms[firstKept].block;
110
+ const removed = [[span[0], kept.start], ...deadTail(arms, truth, firstKept)];
111
+ return {
112
+ span,
113
+ kept: undefined,
114
+ removed,
115
+ recurse: false,
116
+ // `{:else if ` -> `{#if ` (header runs from the block start to its test).
117
+ headerRewrite: { from: kept.start, to: kept.test.start, text: '{#if ' },
118
+ };
119
+ }
120
+ /** The two ranges of `span` that fall outside the kept inner `[s, e]`. */
121
+ function aroundKept(span, inner) {
122
+ if (!inner)
123
+ return [span]; // empty consequent -> the whole chain is removed
124
+ const out = [];
125
+ if (span[0] < inner[0])
126
+ out.push([span[0], inner[0]]);
127
+ if (inner[1] < span[1])
128
+ out.push([inner[1], span[1]]);
129
+ return out;
130
+ }
131
+ /** Source span covered by a fragment's nodes, or `null` if it is empty. */
132
+ function fragmentSpan(fragment) {
133
+ const nodes = fragment?.nodes ?? [];
134
+ if (nodes.length === 0)
135
+ return null;
136
+ return [nodes[0].start, nodes[nodes.length - 1].end];
137
+ }
138
+ /**
139
+ * Removed ranges for provably-false arms AFTER `from` in a chain whose head we
140
+ * keep. Each dead arm spans from its own block start up to the next arm's block
141
+ * start (or, for the last arm, up to the end of its consequent) — matching the
142
+ * transform's surgical deletion exactly.
143
+ */
144
+ function deadTail(arms, truth, from) {
145
+ const removed = [];
146
+ for (let i = from + 1; i < arms.length; i++) {
147
+ const t = truth[i];
148
+ if (!(t.known && !t.value))
149
+ continue; // not provably dead -> keep
150
+ const arm = arms[i];
151
+ const nextBlock = arms[i + 1]?.block;
152
+ const end = nextBlock ? nextBlock.start : consequentEnd(arm.consequent, arm.block.end);
153
+ removed.push([arm.block.start, end]);
154
+ }
155
+ return removed;
156
+ }
157
+ /** End offset of a consequent fragment (its last node), or a fallback. */
158
+ export function consequentEnd(fragment, fallback) {
159
+ const nodes = fragment?.nodes ?? [];
160
+ return nodes.length ? nodes[nodes.length - 1].end : fallback;
161
+ }
162
+ /** Is `node` fully contained in any of the given spans? */
163
+ export function inSpans(node, spans) {
164
+ return spans.some(([a, b]) => node.start >= a && node.end <= b);
165
+ }
166
+ /**
167
+ * Compute the source spans that genuinely vanish from a component's output when
168
+ * its plan is applied — i.e. the markup deleted by dead-`{#if}` folding. This
169
+ * is the SAME predicate the transform uses (both go through {@link decideChain}),
170
+ * so a call site is excluded from a child's prop profile iff the transform would
171
+ * actually delete it (docs §2.1 cascade).
172
+ *
173
+ * We mirror the transform's walk: a chain whose head we keep is recursed into
174
+ * (nested blocks can still fold); a chain we rewrote/collapsed is not, and its
175
+ * span is skipped on later visits via `inSpans`.
176
+ */
177
+ export function computeDeadSpans(fragment, env, setEnv) {
178
+ if (env.size === 0 && setEnv.size === 0)
179
+ return [];
180
+ const dead = [];
181
+ walk(fragment, null, {
182
+ IfBlock(node, { next }) {
183
+ // `elseif` IfBlocks are the continuation of a chain we already own from
184
+ // its head; skip them. Also skip any block inside a region we removed.
185
+ if (node.elseif || inSpans(node, dead))
186
+ return;
187
+ const decision = decideChain(node, env, setEnv);
188
+ for (const r of decision.removed)
189
+ dead.push(r);
190
+ if (decision.recurse)
191
+ next();
192
+ },
193
+ });
194
+ return dead;
195
+ }
@@ -1,6 +1,6 @@
1
- import { type ReadFile, type Resolve } from './analyze';
2
- import type { Parse } from './parse';
3
- import type { ComponentId, EditResult } from './ir';
1
+ import { type ReadFile, type Resolve } from './analyze.js';
2
+ import type { Parse } from './parse.js';
3
+ import type { ComponentId, EditResult } from './ir.js';
4
4
  /**
5
5
  * dev shake granularity (docs/RUST-MIGRATION.md §3 M2 / ARCHITECTURE §6.2):
6
6
  * - `'coarse'` — every change re-reads, re-parses, and re-analyzes the whole
package/dist/engine.js ADDED
@@ -0,0 +1,109 @@
1
+ import { analyzeInput, buildAnalyzeInput } from './analyze.js';
2
+ import { transformAll } from './transform.js';
3
+ /**
4
+ * A long-lived, incremental whole-program shaker for `vite dev` (docs §2.1, §3
5
+ * M2). Construct it with the FULL file set (the Shell's FS scan — the call-site
6
+ * completeness boundary, ARCHITECTURE §6.2) and the same `resolve`/`readFile` the
7
+ * build path uses; call {@link init} once, then {@link update} on every file
8
+ * change. `update` returns the components whose SLIMMED OUTPUT changed, which the
9
+ * Shell turns into the widened HMR boundary.
10
+ *
11
+ * L2 monomorphization is intentionally NOT applied in dev (its net-win gate is a
12
+ * whole-program measurement that is expensive to keep incrementally correct); dev
13
+ * covers L0/L1/L1.5 only (docs §5 risks).
14
+ */
15
+ export class DevShaker {
16
+ entries = new Set();
17
+ resolve;
18
+ readFile;
19
+ mode;
20
+ /** Non-default parser (e.g. rsvelte), or `undefined` for svelte/compiler. */
21
+ parse;
22
+ /** Content-keyed AST cache — only changed files re-parse (§2.2). */
23
+ parseCache = new Map();
24
+ /** Last-seen source per file, so an update re-reads only what changed. */
25
+ codeCache = new Map();
26
+ /** Current slimmed output per `.svelte` id (the live shake result). */
27
+ output = {};
28
+ constructor(files, resolve, readFile, mode = 'incremental', parse) {
29
+ for (const id of Array.isArray(files) ? files : [files])
30
+ this.entries.add(id);
31
+ this.resolve = resolve;
32
+ this.readFile = readFile;
33
+ this.mode = mode;
34
+ this.parse = parse;
35
+ }
36
+ /** Full initial shake of the program. Returns the slimmed source per file. */
37
+ async init() {
38
+ this.output = await this.shake();
39
+ return this.output;
40
+ }
41
+ /** The current slimmed source for a file, or `undefined` if not in the program. */
42
+ get(id) {
43
+ return this.output[id];
44
+ }
45
+ /** A copy of the current whole-program output (every file's slimmed source). */
46
+ snapshot() {
47
+ return { ...this.output };
48
+ }
49
+ /**
50
+ * Apply a batch of file changes and re-shake. Returns the delta: the
51
+ * components whose output changed (a superset of the edited files — a call-site
52
+ * edit can change a child's residual) and the ones no longer in the program.
53
+ */
54
+ async update(change) {
55
+ const incremental = this.mode === 'incremental';
56
+ for (const id of change.removed ?? []) {
57
+ this.entries.delete(id);
58
+ this.codeCache.delete(id);
59
+ this.parseCache.delete(id);
60
+ }
61
+ for (const id of change.added ?? []) {
62
+ this.entries.add(id);
63
+ if (incremental)
64
+ this.codeCache.set(id, await this.readFile(id));
65
+ }
66
+ for (const id of change.changed ?? []) {
67
+ // Re-read into the code cache; `parseCached` re-parses on the content
68
+ // mismatch, so no explicit parse-cache invalidation is needed here.
69
+ if (incremental)
70
+ this.codeCache.set(id, await this.readFile(id));
71
+ }
72
+ const prev = this.output;
73
+ const next = await this.shake();
74
+ this.output = next;
75
+ const changed = {};
76
+ for (const id of Object.keys(next)) {
77
+ if (prev[id] !== next[id])
78
+ changed[id] = next[id];
79
+ }
80
+ const removed = Object.keys(prev).filter((id) => !(id in next));
81
+ return { changed, removed };
82
+ }
83
+ /**
84
+ * Run the whole-program shake. In `'incremental'` mode it reads through the
85
+ * code/parse caches (so unchanged files are neither re-read nor re-parsed); in
86
+ * `'coarse'` mode it bypasses both for a from-scratch rebuild. Both produce
87
+ * identical output — only the work differs.
88
+ */
89
+ async shake() {
90
+ const useCache = this.mode === 'incremental';
91
+ const read = useCache ? this.cachedReadFile : this.readFile;
92
+ // Share one cache between the crawl and the analysis so a non-default parser
93
+ // runs once per file (persistent in incremental, a throwaway in coarse). With
94
+ // the default parser this stays `undefined` in coarse mode — unchanged.
95
+ const parseCache = useCache ? this.parseCache : this.parse ? new Map() : undefined;
96
+ const input = await buildAnalyzeInput([...this.entries], this.resolve, read, parseCache, this.parse);
97
+ const { models, plans } = analyzeInput(input, parseCache);
98
+ return transformAll(models, plans);
99
+ }
100
+ /** Read through the code cache, disk-reading and caching on a miss. */
101
+ cachedReadFile = async (id) => {
102
+ const cached = this.codeCache.get(id);
103
+ if (cached !== undefined)
104
+ return cached;
105
+ const code = await this.readFile(id);
106
+ this.codeCache.set(id, code);
107
+ return code;
108
+ };
109
+ }
@@ -1,5 +1,5 @@
1
- import type { AnyNode } from './parse';
2
- import type { Literal } from './ir';
1
+ import type { AnyNode } from './parse.js';
2
+ import type { Literal } from './ir.js';
3
3
  export type EvalResult = {
4
4
  known: true;
5
5
  value: Literal;