Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bundle-heavy-imports.md
Original file line number Diff line number Diff line change
@@ -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).
26 changes: 26 additions & 0 deletions docs/src/content/docs/ja/rules/perf009.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
title: PERF009 · 重い依存の import
description: 大きくツリーシェイクできないパッケージの import を避けます。
---

**重大度:** info · **カテゴリ:** performance

## チェック内容

よく知られた重い/ツリーシェイク不可のパッケージ(現状は `lodash`・`moment`)からの `import` を検出します。完全一致で判定するため、`lodash/debounce` のようなサブパス import は対象外です。`src/**/*.svelte` のスクリプトを静的(CLI)解析します。

## なぜ重要か

大きくツリーシェイクできないパッケージを import すると、一部しか使っていなくてもバンドルに丸ごと取り込まれ、ページ読み込みが遅くなります。

## 修正方法

```svelte
<script>
// import _ from 'lodash'; の代わりに
import debounce from 'lodash/debounce'; // または lodash-es

// import moment from 'moment'; の代わりに
import { format } from 'date-fns'; // または dayjs
</script>
```
26 changes: 26 additions & 0 deletions docs/src/content/docs/rules/perf009.md
Original file line number Diff line number Diff line change
@@ -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
<script>
// Instead of: import _ from 'lodash';
import debounce from 'lodash/debounce'; // or use lodash-es

// Instead of: import moment from 'moment';
import { format } from 'date-fns'; // or dayjs
</script>
```
52 changes: 52 additions & 0 deletions docs/superpowers/specs/2026-07-01-bundle-heavy-imports-design.md
Original file line number Diff line number Diff line change
@@ -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 `<script>` (ESTree `ImportDeclaration.source.value`).

### Rule (via the shared `componentRule` factory)

`ComponentCategory` widens to include `'performance'` (component-scoped perf).
PERF009 checks each import specifier against an allowlist:

```
HEAVY_PACKAGES = {
lodash: 'Import a submodule (lodash/debounce) or use lodash-es for tree-shaking.',
moment: 'Use a lighter date library (date-fns, dayjs) — moment is large and not tree-shakeable.',
}
```

Matched **exactly** (`lodash`, not `lodash/debounce` — the subpath form is the fix).
`severity: 'info'`. File-unit scored like the other component rules.

## Testing

- Parser facts: `imports` collects instance + module script specifiers; subpath
and unrelated imports are recorded verbatim.
- Rule: flags `lodash` / `moment`; passes `lodash/debounce`, `date-fns`, and a
component with no heavy import; no-op when `ctx.components` is unset.
- Docs (en+ja), changeset. Full `pnpm -r test` + typecheck + lint + docs green.

## Out of scope

- Measuring actual byte size / bundle analysis (needs a bundler) — allowlist only.
- Configurable allowlist (config surface) — ship sensible defaults first.
- `import * as X` namespace-import heuristics (bundler-dependent; deferred).
11 changes: 10 additions & 1 deletion packages/cli/src/providers/source/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ export async function collectComponentFacts(rt: Runtime, cwd: string): Promise<C
const source = await rt.readFile(rt.join(cwd, rel));
return { file: rel, ...parseComponentFacts(source, rel) };
} catch {
return { file: rel, eachBlocks: [], effects: [], htmlTags: [], javascriptUrls: [], loc: 0, propCount: 0 };
return {
file: rel,
eachBlocks: [],
effects: [],
htmlTags: [],
javascriptUrls: [],
loc: 0,
propCount: 0,
imports: []
};
}
})
);
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/providers/source/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,13 @@ function countLines(source: string): number {
return source.split('\n').length - (source.endsWith('\n') ? 1 : 0);
}

/** Module specifiers of every `import` in an ESTree program (Bundle PERF009). */
function collectImportSources(program: Node, acc: string[]): void {
walkEstree(program, (n) => {
if (n.type === 'ImportDeclaration' && typeof n.source?.value === 'string') acc.push(n.source.value);
});
}

/** Parse a component's reactivity/correctness + security + architecture facts (CLI/static only). */
export function parseComponentFacts(
source: string,
Expand All @@ -490,6 +497,7 @@ export function parseComponentFacts(
javascriptUrls: SourceSpan[];
loc: number;
propCount: number;
imports: string[];
} {
const ast = parse(source, { modern: true, filename }) as Node;
const eachBlocks: EachBlockFact[] = [];
Expand All @@ -499,10 +507,15 @@ export function parseComponentFacts(
collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
const loc = countLines(source);

// Imports live in either the instance (<script>) or module (<script module>) program.
const imports: string[] = [];
if (ast.module?.content) collectImportSources(ast.module.content, imports);

const effects: EffectFact[] = [];
let propCount = 0;
const program = ast.instance?.content;
if (program) {
collectImportSources(program, imports);
propCount = countProps(program);
const stateNames = new Set<string>();
walkEstree(program, (n) => {
Expand All @@ -520,5 +533,5 @@ export function parseComponentFacts(
});
});
}
return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount };
return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports };
}
19 changes: 19 additions & 0 deletions packages/cli/test/parse-component-facts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ describe('parseComponentFacts — architecture (ARCH001/ARCH002)', () => {
});
});

describe('parseComponentFacts — imports (PERF009)', () => {
it('collects import specifiers from the instance script', () => {
const src = "<script>import _ from 'lodash'; import { onMount } from 'svelte';</script>";
expect(parseComponentFacts(src, 'C.svelte').imports).toEqual(['lodash', 'svelte']);
});
it('collects imports from the module script too', () => {
const src = "<script module>import x from 'a';</script><script>import y from 'b';</script>";
expect(parseComponentFacts(src, 'C.svelte').imports.sort()).toEqual(['a', 'b']);
});
it('records subpath specifiers verbatim (not normalized)', () => {
expect(parseComponentFacts("<script>import d from 'lodash/debounce';</script>", 'C.svelte').imports).toEqual([
'lodash/debounce'
]);
});
it('reports no imports for a component without a script', () => {
expect(parseComponentFacts('<p>hi</p>', 'C.svelte').imports).toEqual([]);
});
});

describe('collectComponentFacts (memory runtime)', () => {
it('scans every .svelte under src, including $lib', async () => {
const rt = createMemoryRuntime({
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/rules/component-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface ComponentIssue {
}

/** Categories that component-scoped rules report under (CLI/static source analysis). */
export type ComponentCategory = Extract<Category, 'correctness' | 'security' | 'architecture'>;
export type ComponentCategory = Extract<Category, 'correctness' | 'security' | 'architecture' | 'performance'>;

export interface ComponentRuleOptions {
id: string;
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -85,7 +86,8 @@ export const allRules: Rule[] = [
sec001Html,
sec002JavascriptUrl,
arch001ComponentSize,
arch002PropCount
arch002PropCount,
perf009HeavyImport
];

export {
Expand Down Expand Up @@ -132,7 +134,8 @@ export {
sec001Html,
sec002JavascriptUrl,
arch001ComponentSize,
arch002PropCount
arch002PropCount,
perf009HeavyImport
};

export interface RuleInfo {
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/rules/performance/perf009-heavy-import.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string>();
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;
}
});
1 change: 1 addition & 0 deletions packages/core/test/architecture-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const comp = (over: Partial<ComponentFacts>): ComponentFacts => ({
javascriptUrls: [],
loc: 10,
propCount: 0,
imports: [],
...over
});

Expand Down
49 changes: 49 additions & 0 deletions packages/core/test/bundle-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions packages/core/test/correctness-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const comp = (over: Partial<ComponentFacts>): ComponentFacts => ({
javascriptUrls: [],
loc: 10,
propCount: 0,
imports: [],
...over
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/security-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const comp = (over: Partial<ComponentFacts>): ComponentFacts => ({
javascriptUrls: [],
loc: 10,
propCount: 0,
imports: [],
...over
});

Expand Down