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
});