svelte-shaker 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/analyze.d.ts CHANGED
@@ -3,6 +3,11 @@ import { type AnalyzeInput, type ComponentId, type ComponentPlan, type Literal }
3
3
  import { type Span } from './dead.js';
4
4
  export type Resolve = (source: string, importer: ComponentId) => Promise<ComponentId | null> | ComponentId | null;
5
5
  export type ReadFile = (id: ComponentId) => Promise<string> | string;
6
+ /** Synchronous variants of {@link Resolve}/{@link ReadFile} for callers that
7
+ * cannot await — e.g. an ESLint rule, which runs synchronously. Used by
8
+ * {@link buildAnalyzeInputSync}. */
9
+ export type ResolveSync = (source: string, importer: ComponentId) => ComponentId | null;
10
+ export type ReadFileSync = (id: ComponentId) => string;
6
11
  /** One declared prop in a `$props()` destructuring. */
7
12
  export interface PropDecl {
8
13
  /** The EXTERNAL prop name — the destructure KEY (`prop` in `prop: alias`).
@@ -148,12 +153,50 @@ export declare function analyzeInput(input: AnalyzeInput, parseCache?: ParseCach
148
153
  * value set), keeping the produced model set — and thus the output — identical.
149
154
  */
150
155
  export declare function buildAnalyzeInput(entries: ComponentId | ComponentId[], resolve: Resolve, readFile: ReadFile, parseCache?: ParseCache, parse?: Parse): Promise<AnalyzeInput>;
156
+ /**
157
+ * Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
158
+ * (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
159
+ * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
160
+ * pins it identical to the async path, so keep the two bodies in lockstep.
161
+ */
162
+ export declare function buildAnalyzeInputSync(entries: ComponentId | ComponentId[], resolve: ResolveSync, readFile: ReadFileSync, parseCache?: ParseCache, parse?: Parse): AnalyzeInput;
151
163
  /**
152
164
  * Dead `{#if}` spans per component implied by `plans`, via the SAME shared
153
165
  * predicate the transform uses ({@link computeDeadSpans}). A bailed component
154
166
  * folds nothing, so it has no dead spans.
155
167
  */
156
168
  export declare function deadSpansForPlans(models: Map<ComponentId, FileModel>, plans: Map<ComponentId, ComponentPlan>): Map<ComponentId, Span[]>;
169
+ /** One declared prop that no call site in the program ever passes. `start`/`end`
170
+ * are UTF-16 offsets of the prop's `$props()` destructuring property, for direct
171
+ * source mapping by a consumer (e.g. an ESLint rule). */
172
+ export interface UnpassedProp {
173
+ /** The external prop name (what a caller would pass). */
174
+ name: string;
175
+ start: number;
176
+ end: number;
177
+ }
178
+ /**
179
+ * Declared props that NO call site in the analyzed program ever passes — neither
180
+ * explicitly (`<C p=…>` / `bind:p`), via a spread, nor as body content/`{#snippet}`.
181
+ * These are "dead" from the consumer side: the component declares an input no one
182
+ * supplies, so it is always its default. A lint-oriented counterpart to the
183
+ * build-time fold (svelte-shaker would const-fold such a prop to its default).
184
+ *
185
+ * Soundness — only HIGH-CONFIDENCE reports, mirroring the folder's own caution:
186
+ * - a component that BAILED (escaped as a value, `accessors`, etc.) is skipped —
187
+ * its prop profile is unknowable;
188
+ * - a component with ZERO call sites is skipped — it is an entry/route/unused
189
+ * component whose props may be supplied OUTSIDE the analyzed graph (a SvelteKit
190
+ * `+page.svelte`'s `data`, a framework mount, a not-yet-rendered component);
191
+ * - a prop is reported only when EVERY call site neither names it nor carries a
192
+ * spread that could set it (`readCallSite` already folds `bind:`, known
193
+ * spreads, and `children`/snippet body into `explicit`/`hadSpread`).
194
+ *
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.
198
+ */
199
+ export declare function findNeverPassedProps(input: AnalyzeInput): Map<ComponentId, UnpassedProp[]>;
157
200
  /**
158
201
  * Remap a plan map keyed by EXTERNAL prop name (`constFold` / `narrow`) to one
159
202
  * keyed by the LOCAL binding name each prop introduces. Call-site analysis and
package/dist/analyze.js CHANGED
@@ -174,6 +174,72 @@ export async function buildAnalyzeInput(entries, resolve, readFile, parseCache,
174
174
  }
175
175
  return { files, edges, entries: entryList };
176
176
  }
177
+ /**
178
+ * Synchronous twin of {@link buildAnalyzeInput} for callers that cannot await
179
+ * (an ESLint rule runs synchronously). Byte-for-byte the same crawl with sync
180
+ * `resolve`/`readFile`; the `tests/build-analyze-input-sync` differential test
181
+ * pins it identical to the async path, so keep the two bodies in lockstep.
182
+ */
183
+ export function buildAnalyzeInputSync(entries, resolve, readFile, parseCache, parse) {
184
+ const entryList = Array.isArray(entries) ? [...entries] : [entries];
185
+ const files = [];
186
+ const edges = [];
187
+ const queue = [...entryList];
188
+ const seen = new Set(queue);
189
+ while (queue.length > 0) {
190
+ const id = queue.shift();
191
+ const code = readFile(id);
192
+ files.push({ id, code });
193
+ const ast = parseCached(id, code, parseCache, parse);
194
+ const instance = ast.instance;
195
+ if (!instance)
196
+ continue;
197
+ const barrelLocals = new Map();
198
+ const namespaceSources = new Map();
199
+ const directChildren = [];
200
+ for (const imp of importSources(instance)) {
201
+ if (imp.imported === '*') {
202
+ namespaceSources.set(imp.local, imp.value);
203
+ continue;
204
+ }
205
+ if (imp.imported === 'default' && isSvelte(imp.value)) {
206
+ const childId = resolve(imp.value, id);
207
+ if (childId) {
208
+ edges.push({ from: id, local: imp.local, to: childId, kind: 'default-svelte' });
209
+ directChildren.push(childId);
210
+ }
211
+ continue;
212
+ }
213
+ const childId = resolveThroughBarrelSync(imp.value, imp.imported, id, resolve, readFile);
214
+ if (childId) {
215
+ edges.push({ from: id, local: imp.local, to: childId, kind: 'barrel' });
216
+ barrelLocals.set(imp.local, childId);
217
+ }
218
+ }
219
+ const nsChildren = [];
220
+ if (namespaceSources.size > 0) {
221
+ for (const tag of memberComponentTags(ast)) {
222
+ const dot = tag.indexOf('.');
223
+ const source = namespaceSources.get(tag.slice(0, dot));
224
+ if (source == null)
225
+ continue;
226
+ const childId = resolveThroughBarrelSync(source, tag.slice(dot + 1), id, resolve, readFile);
227
+ if (childId) {
228
+ edges.push({ from: id, local: tag, to: childId, kind: 'namespace' });
229
+ nsChildren.push(childId);
230
+ }
231
+ }
232
+ }
233
+ const rendered = collectBarrelChildIds(ast, barrelLocals);
234
+ for (const childId of [...directChildren, ...rendered, ...nsChildren]) {
235
+ if (!seen.has(childId)) {
236
+ seen.add(childId);
237
+ queue.push(childId);
238
+ }
239
+ }
240
+ }
241
+ return { files, edges, entries: entryList };
242
+ }
177
243
  /**
178
244
  * Aggregate every component's call sites into per-child {@link Usage}, EXCLUDING
179
245
  * any `<Child/>` whose node falls inside a dead `{#if}` span of its containing
@@ -279,6 +345,67 @@ export function deadSpansForPlans(models, plans) {
279
345
  }
280
346
  return out;
281
347
  }
348
+ /**
349
+ * Declared props that NO call site in the analyzed program ever passes — neither
350
+ * explicitly (`<C p=…>` / `bind:p`), via a spread, nor as body content/`{#snippet}`.
351
+ * These are "dead" from the consumer side: the component declares an input no one
352
+ * supplies, so it is always its default. A lint-oriented counterpart to the
353
+ * build-time fold (svelte-shaker would const-fold such a prop to its default).
354
+ *
355
+ * Soundness — only HIGH-CONFIDENCE reports, mirroring the folder's own caution:
356
+ * - a component that BAILED (escaped as a value, `accessors`, etc.) is skipped —
357
+ * its prop profile is unknowable;
358
+ * - a component with ZERO call sites is skipped — it is an entry/route/unused
359
+ * component whose props may be supplied OUTSIDE the analyzed graph (a SvelteKit
360
+ * `+page.svelte`'s `data`, a framework mount, a not-yet-rendered component);
361
+ * - a prop is reported only when EVERY call site neither names it nor carries a
362
+ * spread that could set it (`readCallSite` already folds `bind:`, known
363
+ * spreads, and `children`/snippet body into `explicit`/`hadSpread`).
364
+ *
365
+ * Because missing an edge (e.g. an unfollowed barrel) only DROPS call sites, it
366
+ * can only make this UNDER-report (the component looks unused and is skipped),
367
+ * never over-report — so an incomplete crawl stays false-positive-free.
368
+ */
369
+ export function findNeverPassedProps(input) {
370
+ const models = buildModels(input);
371
+ // Stamp escape bails up front (same union as `analyzeInput`) so escaped
372
+ // components are skipped below.
373
+ const escaped = new Set();
374
+ for (const model of models.values())
375
+ for (const id of model.escapedComponents)
376
+ escaped.add(id);
377
+ for (const id of escaped) {
378
+ const model = models.get(id);
379
+ if (model && !model.bailReasons.includes(ESCAPE_REASON))
380
+ model.bailReasons.push(ESCAPE_REASON);
381
+ }
382
+ // Every textual call site counts (no cascade dead-span filtering): a prop passed
383
+ // only at a folded-away site is still author-written, so we do not flag it.
384
+ const usage = buildUsage(models, new Map());
385
+ const out = new Map();
386
+ for (const model of models.values()) {
387
+ if (model.bailReasons.length > 0)
388
+ continue;
389
+ if (!model.props || model.props.length === 0)
390
+ continue;
391
+ const sites = usage.get(model.id)?.sites ?? [];
392
+ if (sites.length === 0)
393
+ continue;
394
+ const unpassed = [];
395
+ for (const decl of model.props) {
396
+ const maybePassed = sites.some((s) => s.explicit.has(decl.name) || s.hadSpread);
397
+ if (maybePassed)
398
+ continue;
399
+ const prop = decl.property;
400
+ if (typeof prop.start !== 'number' || typeof prop.end !== 'number')
401
+ continue;
402
+ unpassed.push({ name: decl.name, start: prop.start, end: prop.end });
403
+ }
404
+ if (unpassed.length > 0)
405
+ out.set(model.id, unpassed);
406
+ }
407
+ return out;
408
+ }
282
409
  /**
283
410
  * Remap a plan map keyed by EXTERNAL prop name (`constFold` / `narrow`) to one
284
411
  * keyed by the LOCAL binding name each prop introduces. Call-site analysis and
@@ -1103,6 +1230,58 @@ async function resolveThroughBarrel(source, imported, importer, resolve, readFil
1103
1230
  }
1104
1231
  return null;
1105
1232
  }
1233
+ /** Synchronous twin of {@link resolveThroughBarrel} (see {@link
1234
+ * buildAnalyzeInputSync}). Keep in lockstep with the async body above. */
1235
+ function resolveThroughBarrelSync(source, imported, importer, resolve, readFile, hops = 0) {
1236
+ if (hops > MAX_BARREL_HOPS)
1237
+ return null;
1238
+ const targetId = resolve(source, importer);
1239
+ if (!targetId)
1240
+ return null;
1241
+ if (isSvelte(source) || isSvelte(targetId)) {
1242
+ return imported === 'default' || imported === '*' ? targetId : null;
1243
+ }
1244
+ let code;
1245
+ try {
1246
+ code = readFile(targetId);
1247
+ }
1248
+ catch {
1249
+ return null;
1250
+ }
1251
+ const body = parseModuleBody(code, targetId);
1252
+ if (!body)
1253
+ return null;
1254
+ for (const stmt of body) {
1255
+ if (stmt.type === 'ExportNamedDeclaration' && stmt.source?.value) {
1256
+ for (const spec of stmt.specifiers ?? []) {
1257
+ if (specName(spec.exported) !== imported)
1258
+ continue;
1259
+ return resolveThroughBarrelSync(String(stmt.source.value), specName(spec.local) ?? 'default', targetId, resolve, readFile, hops + 1);
1260
+ }
1261
+ continue;
1262
+ }
1263
+ if (stmt.type === 'ExportNamedDeclaration' && !stmt.source) {
1264
+ for (const spec of stmt.specifiers ?? []) {
1265
+ if (specName(spec.exported) !== imported)
1266
+ continue;
1267
+ const localName = specName(spec.local);
1268
+ if (!localName)
1269
+ continue;
1270
+ const found = followLocalImport(body, localName);
1271
+ if (!found)
1272
+ return null;
1273
+ return resolveThroughBarrelSync(found.value, found.imported, targetId, resolve, readFile, hops + 1);
1274
+ }
1275
+ continue;
1276
+ }
1277
+ if (stmt.type === 'ExportAllDeclaration' && stmt.source?.value) {
1278
+ const via = resolveThroughBarrelSync(String(stmt.source.value), imported, targetId, resolve, readFile, hops + 1);
1279
+ if (via)
1280
+ return via;
1281
+ }
1282
+ }
1283
+ return null;
1284
+ }
1106
1285
  /** Find the import in `body` that binds `localName`, as an {@link ImportInfo}. */
1107
1286
  function followLocalImport(body, localName) {
1108
1287
  for (const stmt of body) {
package/dist/index.d.ts CHANGED
@@ -3,9 +3,10 @@ import { type Parse } from './parse.js';
3
3
  import { type MonomorphizeOptions, type MonomorphizeResult } from './mono.js';
4
4
  import type { ComponentId } from './ir.js';
5
5
  export type { ComponentId, AnalyzeInput, InputFile, ResolvedEdge, EdgeKind, EditResult, } from './ir.js';
6
- export type { Resolve, ReadFile } from './analyze.js';
6
+ export type { Resolve, ReadFile, ResolveSync, ReadFileSync } from './analyze.js';
7
7
  export type { Parse, Root } from './parse.js';
8
- export { analyze, analyzeInput, buildAnalyzeInput } from './analyze.js';
8
+ export { analyze, analyzeInput, buildAnalyzeInput, buildAnalyzeInputSync, deadSpansForPlans, findNeverPassedProps, } from './analyze.js';
9
+ export type { UnpassedProp } from './analyze.js';
9
10
  export { DevShaker, type DevMode, type DevShakerChange } from './engine.js';
10
11
  export { transformAll, transformAllWithMono } from './transform.js';
11
12
  export { monomorphize, DEFAULT_MONO_OPTIONS, type MonomorphizeOptions, type MonomorphizeResult, type Variant, type CallSiteBinding, } from './mono.js';
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { analyze, analyzeInput, buildAnalyzeInput, } from './analyze.js';
2
2
  import { parseSvelte } from './parse.js';
3
3
  import { transformAll, transformAllWithMono } from './transform.js';
4
4
  import { monomorphize, DEFAULT_MONO_OPTIONS, } from './mono.js';
5
- export { analyze, analyzeInput, buildAnalyzeInput } from './analyze.js';
5
+ export { analyze, analyzeInput, buildAnalyzeInput, buildAnalyzeInputSync, deadSpansForPlans, findNeverPassedProps, } from './analyze.js';
6
6
  export { DevShaker } from './engine.js';
7
7
  export { transformAll, transformAllWithMono } from './transform.js';
8
8
  export { monomorphize, DEFAULT_MONO_OPTIONS, } from './mono.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-shaker",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Tree shaking for Svelte components",
5
5
  "keywords": [
6
6
  "dead-code-elimination",
@@ -30,15 +30,18 @@
30
30
  "exports": {
31
31
  ".": {
32
32
  "types": "./dist/index.d.ts",
33
- "import": "./dist/index.js"
33
+ "import": "./dist/index.js",
34
+ "default": "./dist/index.js"
34
35
  },
35
36
  "./vite": {
36
37
  "types": "./dist/vite.d.ts",
37
- "import": "./dist/vite.js"
38
+ "import": "./dist/vite.js",
39
+ "default": "./dist/vite.js"
38
40
  },
39
41
  "./node": {
40
42
  "types": "./dist/scan.d.ts",
41
- "import": "./dist/scan.js"
43
+ "import": "./dist/scan.js",
44
+ "default": "./dist/scan.js"
42
45
  },
43
46
  "./package.json": "./package.json"
44
47
  },