diff --git a/.changeset/bundle-heavy-imports.md b/.changeset/bundle-heavy-imports.md new file mode 100644 index 000000000..bbf0ff324 --- /dev/null +++ b/.changeset/bundle-heavy-imports.md @@ -0,0 +1,11 @@ +--- +'@svelte-vitals/core': minor +'svelte-vitals': minor +'@svelte-vitals/mcp': minor +--- + +Add **PERF009 (heavy dependency import)** — the Bundle slice of #69. Flags an +`import` from a well-known heavy / non-tree-shakeable package (`lodash`, `moment`), +matched by exact specifier so subpath imports like `lodash/debounce` pass. +Reported under the `performance` category (info). `ComponentFacts` gains `imports` +(module specifiers from the instance + module scripts). diff --git a/docs/src/content/docs/ja/rules/perf009.md b/docs/src/content/docs/ja/rules/perf009.md new file mode 100644 index 000000000..a05dabe7f --- /dev/null +++ b/docs/src/content/docs/ja/rules/perf009.md @@ -0,0 +1,26 @@ +--- +title: PERF009 · 重い依存の import +description: 大きくツリーシェイクできないパッケージの import を避けます。 +--- + +**重大度:** info · **カテゴリ:** performance + +## チェック内容 + +よく知られた重い/ツリーシェイク不可のパッケージ(現状は `lodash`・`moment`)からの `import` を検出します。完全一致で判定するため、`lodash/debounce` のようなサブパス import は対象外です。`src/**/*.svelte` のスクリプトを静的(CLI)解析します。 + +## なぜ重要か + +大きくツリーシェイクできないパッケージを import すると、一部しか使っていなくてもバンドルに丸ごと取り込まれ、ページ読み込みが遅くなります。 + +## 修正方法 + +```svelte + +``` diff --git a/docs/src/content/docs/rules/perf009.md b/docs/src/content/docs/rules/perf009.md new file mode 100644 index 000000000..1d1ca85e7 --- /dev/null +++ b/docs/src/content/docs/rules/perf009.md @@ -0,0 +1,26 @@ +--- +title: PERF009 · Heavy dependency import +description: Avoid importing large, non-tree-shakeable packages. +--- + +**Severity:** info · **Category:** performance + +## What it checks + +Flags an `import` from a well-known heavy / non-tree-shakeable package (currently `lodash`, `moment`). Matched by exact specifier, so a subpath import like `lodash/debounce` is **not** flagged. Static (CLI) analysis of `src/**/*.svelte` scripts. + +## Why it matters + +Importing a large, non-tree-shakeable package pulls its whole weight into the bundle even when you use a fraction of it, slowing page load. + +## How to fix + +```svelte + +``` diff --git a/docs/superpowers/specs/2026-07-01-bundle-heavy-imports-design.md b/docs/superpowers/specs/2026-07-01-bundle-heavy-imports-design.md new file mode 100644 index 000000000..ff305d89e --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-bundle-heavy-imports-design.md @@ -0,0 +1,52 @@ +# Bundle depth — heavy dependency imports (PERF009) + +**Date:** 2026-07-01 +**Status:** Approved (per maintainer; #69 Bundle/perf slice) +**Packages:** `@svelte-vitals/core` (rule), `@svelte-vitals/cli` (import capture), `@svelte-vitals/mcp` (surfaces via `allRules`) + +## Goal + +Flag imports of well-known **heavy / non-tree-shakeable packages** that bloat the +bundle — a common "AI wrote `import _ from 'lodash'`" mistake. Allowlist-precise +(exact package match), so no false positives. Reuses the component-body scan +(`ctx.components`, CLI/static). Reported under the existing **performance** category. + +| ID | Check | Severity | +| ------- | ----------------------- | -------- | +| PERF009 | Heavy dependency import | info | + +## Design + +### Facts (`ComponentFacts`) + +- `imports: string[]` — module specifiers of every `import` in the component's + instance and module `"; + expect(parseComponentFacts(src, 'C.svelte').imports).toEqual(['lodash', 'svelte']); + }); + it('collects imports from the module script too', () => { + const src = ""; + expect(parseComponentFacts(src, 'C.svelte').imports.sort()).toEqual(['a', 'b']); + }); + it('records subpath specifiers verbatim (not normalized)', () => { + expect(parseComponentFacts("", 'C.svelte').imports).toEqual([ + 'lodash/debounce' + ]); + }); + it('reports no imports for a component without a script', () => { + expect(parseComponentFacts('

hi

', 'C.svelte').imports).toEqual([]); + }); +}); + describe('collectComponentFacts (memory runtime)', () => { it('scans every .svelte under src, including $lib', async () => { const rt = createMemoryRuntime({ diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 759bd62ee..74b5fa519 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -40,4 +40,6 @@ export interface ComponentFacts { loc: number; /** Named props destructured from `$props()`; 0 when unknowable (rest / non-destructured) (Architecture ARCH002). */ propCount: number; + /** Module specifiers of every `import` in the instance + module scripts (Bundle PERF009). */ + imports: string[]; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93a6d5146..9ba3811d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,7 +73,8 @@ export { sec001Html, sec002JavascriptUrl, arch001ComponentSize, - arch002PropCount + arch002PropCount, + perf009HeavyImport } from './rules/index.js'; export type { RuleInfo } from './rules/index.js'; export { headTagRule } from './rules/seo/head-tag-rule.js'; diff --git a/packages/core/src/rules/component-rule.ts b/packages/core/src/rules/component-rule.ts index 10cba6be6..f2bfcf5a2 100644 --- a/packages/core/src/rules/component-rule.ts +++ b/packages/core/src/rules/component-rule.ts @@ -12,7 +12,7 @@ export interface ComponentIssue { } /** Categories that component-scoped rules report under (CLI/static source analysis). */ -export type ComponentCategory = Extract; +export type ComponentCategory = Extract; export interface ComponentRuleOptions { id: string; diff --git a/packages/core/src/rules/index.ts b/packages/core/src/rules/index.ts index 297e970c6..e6d42c344 100644 --- a/packages/core/src/rules/index.ts +++ b/packages/core/src/rules/index.ts @@ -40,6 +40,7 @@ import { seo030HeadingOrder } from './seo/seo030-heading-order.js'; import { correct001EachKey, correct002EffectDerived } from './correctness/correct001-002.js'; import { sec001Html, sec002JavascriptUrl } from './security/sec001-002.js'; import { arch001ComponentSize, arch002PropCount } from './architecture/arch001-002.js'; +import { perf009HeavyImport } from './performance/perf009-heavy-import.js'; export const allRules: Rule[] = [ seo001Title, @@ -85,7 +86,8 @@ export const allRules: Rule[] = [ sec001Html, sec002JavascriptUrl, arch001ComponentSize, - arch002PropCount + arch002PropCount, + perf009HeavyImport ]; export { @@ -132,7 +134,8 @@ export { sec001Html, sec002JavascriptUrl, arch001ComponentSize, - arch002PropCount + arch002PropCount, + perf009HeavyImport }; export interface RuleInfo { diff --git a/packages/core/src/rules/performance/perf009-heavy-import.ts b/packages/core/src/rules/performance/perf009-heavy-import.ts new file mode 100644 index 000000000..adea30afb --- /dev/null +++ b/packages/core/src/rules/performance/perf009-heavy-import.ts @@ -0,0 +1,34 @@ +import { componentRule } from '../component-rule.js'; + +/** + * Well-known heavy / non-tree-shakeable packages, mapped to the lighter alternative. + * Matched by exact specifier — a subpath import (`lodash/debounce`) is the fix, not a hit. + */ +const HEAVY_PACKAGES: Record = { + lodash: 'import a submodule (lodash/debounce) or use lodash-es for tree-shaking', + moment: 'use a lighter date library (date-fns or dayjs) — moment is large and not tree-shakeable' +}; + +export const perf009HeavyImport = componentRule({ + id: 'PERF009', + title: 'Heavy dependency import', + category: 'performance', + severity: 'info', + label: 'No heavy imports', + recommendation: 'Import a submodule or switch to a lighter, tree-shakeable alternative.', + rationale: + 'Importing a large, non-tree-shakeable package pulls its whole weight into the bundle even when only a fraction is used, slowing load.', + applies: (c) => c.imports.length > 0, + bad: (c) => { + // `Object.hasOwn` (not `in`) so inherited keys like `toString` never match; + // dedupe so the same package imported in both scripts isn't double-penalized. + const seen = new Set(); + const out: { line: number; message: string }[] = []; + for (const src of c.imports) { + if (!Object.hasOwn(HEAVY_PACKAGES, src) || seen.has(src)) continue; + seen.add(src); + out.push({ line: 0, message: `Heavy import "${src}" — ${HEAVY_PACKAGES[src]}` }); + } + return out; + } +}); diff --git a/packages/core/test/architecture-rules.test.ts b/packages/core/test/architecture-rules.test.ts index 98c46fdd6..fe54f274a 100644 --- a/packages/core/test/architecture-rules.test.ts +++ b/packages/core/test/architecture-rules.test.ts @@ -17,6 +17,7 @@ const comp = (over: Partial): ComponentFacts => ({ javascriptUrls: [], loc: 10, propCount: 0, + imports: [], ...over }); diff --git a/packages/core/test/bundle-rules.test.ts b/packages/core/test/bundle-rules.test.ts new file mode 100644 index 000000000..5b22ce16d --- /dev/null +++ b/packages/core/test/bundle-rules.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { perf009HeavyImport } from '../src/index.js'; +import { defineConfig, defaultProject } from '../src/types.js'; +import type { ComponentFacts } from '../src/component.js'; +import type { RuleContext } from '../src/rule.js'; + +const config = defineConfig({}); +const base = { heads: [], project: defaultProject, config }; +const fails = (rs: { detection: { presence: string; value: string } }[]) => + rs.filter((r) => r.detection.presence === 'none' || r.detection.value === 'absent'); +const ctx = (components: ComponentFacts[]): RuleContext => ({ components, ...base }); +const comp = (imports: string[]): ComponentFacts => ({ + file: 'src/lib/C.svelte', + eachBlocks: [], + effects: [], + htmlTags: [], + javascriptUrls: [], + loc: 10, + propCount: 0, + imports +}); + +describe('PERF009 heavy dependency import', () => { + it('flags a bare lodash / moment import', async () => { + const rs = await perf009HeavyImport.check(ctx([comp(['lodash', 'svelte'])])); + expect(fails(rs)).toHaveLength(1); + expect(rs[0]!.category).toBe('performance'); + expect(rs[0]!.message).toContain('lodash'); + }); + it('does not flag a subpath import or a light dependency', async () => { + const rs = await perf009HeavyImport.check(ctx([comp(['lodash/debounce', 'date-fns'])])); + expect(fails(rs)).toHaveLength(0); + expect(rs).toHaveLength(1); // a passing seed + }); + it('does not match inherited Object keys (e.g. "toString")', async () => { + const rs = await perf009HeavyImport.check(ctx([comp(['toString', 'constructor'])])); + expect(fails(rs)).toHaveLength(0); + }); + it('dedupes the same heavy package imported twice (one finding)', async () => { + const rs = await perf009HeavyImport.check(ctx([comp(['lodash', 'lodash'])])); + expect(fails(rs)).toHaveLength(1); + }); + it('emits nothing for a component with no imports', async () => { + expect(await perf009HeavyImport.check(ctx([comp([])]))).toHaveLength(0); + }); + it('emits nothing when the component channel is unset (rendered mode)', async () => { + expect(await perf009HeavyImport.check(base as RuleContext)).toHaveLength(0); + }); +}); diff --git a/packages/core/test/correctness-rules.test.ts b/packages/core/test/correctness-rules.test.ts index 89b2cdda0..980dc4d1a 100644 --- a/packages/core/test/correctness-rules.test.ts +++ b/packages/core/test/correctness-rules.test.ts @@ -17,6 +17,7 @@ const comp = (over: Partial): ComponentFacts => ({ javascriptUrls: [], loc: 10, propCount: 0, + imports: [], ...over }); diff --git a/packages/core/test/security-rules.test.ts b/packages/core/test/security-rules.test.ts index f66d35382..29c70c7eb 100644 --- a/packages/core/test/security-rules.test.ts +++ b/packages/core/test/security-rules.test.ts @@ -17,6 +17,7 @@ const comp = (over: Partial): ComponentFacts => ({ javascriptUrls: [], loc: 10, propCount: 0, + imports: [], ...over });