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
16 changes: 16 additions & 0 deletions .changeset/architecture-category.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@svelte-vitals/core': minor
'svelte-vitals': minor
'@svelte-vitals/mcp': minor
---

Add an **Architecture** category — the third "Svelte Doctor" code-health category,
reusing the component-body scan (CLI/static mode). Deterministic, high-precision
size metrics that flag bloated "god components":

- **ARCH001** Component size: flags a `.svelte` file over 400 lines (info).
- **ARCH002** Prop count: flags a component destructuring more than 10 props from
`$props()` (info).

`ComponentFacts` gains `loc` and `propCount`; the console reporter shows an
Architecture score line.
18 changes: 18 additions & 0 deletions docs/src/content/docs/ja/rules/arch001.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
title: ARCH001 · コンポーネントサイズ
description: 巨大なコンポーネントは分割すべきです。
---

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

## チェック内容

400 行を超える `.svelte` コンポーネントを検出します(`src/**/*.svelte` を静的(CLI)解析)。

## なぜ重要か

巨大なコンポーネントは読みづらく、テストや再利用が難しく、複数の責務を分割すべきサインであることが多いです。AI が生成したコードでよく見られる形です。

## 修正方法

小さく焦点を絞った子コンポーネント(およびロジック用の再利用可能な `.svelte.ts` モジュール)に切り出します。
23 changes: 23 additions & 0 deletions docs/src/content/docs/ja/rules/arch002.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: ARCH002 · プロップ数
description: 多くのプロップを受け取るコンポーネントは責務過多です。
---

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

## チェック内容

`$props()` から 10 個を超えるプロップを分割代入しているコンポーネントを検出します。レスト要素(`...rest`)や分割代入していない `$props()` はカウントしません。

## なぜ重要か

プロップの面積が大きいコンポーネントはたいてい責務過多です。関連するプロップをオブジェクトにまとめるか、コンポーネントを分割すると API が理解しやすくなります。

## 修正方法

```svelte
<script>
// 多数のフラットなプロップではなく、関連するものをオブジェクトにまとめる。
let { user, layout } = $props(); // user: { name, avatar, … }
</script>
```
18 changes: 18 additions & 0 deletions docs/src/content/docs/rules/arch001.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
title: ARCH001 · Component size
description: Very large components should be split up.
---

**Severity:** info · **Category:** architecture

## What it checks

Flags a `.svelte` component longer than 400 lines (static/CLI analysis of `src/**/*.svelte`).

## Why it matters

A very large component is hard to read, test, and reuse, and usually means several responsibilities should be split out — a common shape for AI-generated code.

## How to fix

Extract sections into smaller, focused child components (and reusable `.svelte.ts` modules for logic).
23 changes: 23 additions & 0 deletions docs/src/content/docs/rules/arch002.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: ARCH002 · Prop count
description: Components taking many props are doing too much.
---

**Severity:** info · **Category:** architecture

## What it checks

Flags a component that destructures more than 10 props from `$props()`. A rest element (`...rest`) or a non-destructured `$props()` is not counted.

## Why it matters

A component with a large prop surface is usually doing too much; grouping related props or splitting the component keeps its API understandable.

## How to fix

```svelte
<script>
// Group related props into an object instead of many flat props.
let { user, layout } = $props(); // user: { name, avatar, … }
</script>
```
61 changes: 61 additions & 0 deletions docs/superpowers/specs/2026-06-30-architecture-category-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Architecture category — component-size metrics (ARCH001/ARCH002)

**Date:** 2026-06-30
**Status:** Approved (per maintainer; next slice of #69)
**Packages:** `@svelte-vitals/core` (category + rules), `@svelte-vitals/mcp` (surfaces via `allRules`)

## Goal

Third "Svelte Doctor" code-health category, reusing the component-body scan
(`ctx.components`, CLI/static only). High-precision, pure-counting metrics that flag
AI-bloated "god components" — no overlap with the compiler / svelte-check / eslint.

Taken before the "More Correctness/reactivity" slice: those reactivity heuristics
are lower-precision / partly compiler-covered, which conflicts with the no-false-
positive principle. Architecture metrics are deterministic counts.

| ID | Check | Severity | Default threshold |
| ------- | --------------------------- | -------- | ----------------- |
| ARCH001 | Component too large (lines) | info | > 400 lines |
| ARCH002 | Too many props | info | > 10 props |

`info`, because size/props are advisory smells, not defects.

## Design

### Facts (`ComponentFacts`)

- `loc: number` — source line count of the `.svelte` file.
- `propCount: number` — number of named props destructured from `$props()`
(`let { a, b } = $props()` → 2). A rest element (`...rest`) or a non-destructured
`let p = $props()` makes the count unknowable → `propCount: 0` (never flagged).

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

`ComponentCategory` widens to include `'architecture'`. Both rules use
`severity: 'info'` and thresholds as named constants:

- **ARCH001** — `applies: (c) => c.loc > 0` (skip unanalyzable files — `loc: 0`
is the read/parse-failure fallback, not a real 0-line component); `bad`: one
finding when `loc > MAX_LOC` (400).
- **ARCH002** — `applies: (c) => c.propCount > 0`; `bad`: one finding when
`propCount > MAX_PROPS` (10).

Findings are file-unit scored (route + location = file), like the other component
categories. Console reporter `CATEGORY_ORDER`/`CATEGORY_LABEL` gains 'architecture'
(html/json enumerate dynamically); docs-link test allowlist gains it.

## Out of scope (later)

- Template nesting depth (needs a depth walk — defer to keep this slice tight).
- Configurable thresholds (config surface) — ship sensible defaults first.

## Testing

- Parser facts: `loc` counts lines; `propCount` from a destructured `$props()`;
rest element / non-destructured `$props()` → 0.
- Rules: ARCH001 flags an over-`MAX_LOC` file / passes a small one; ARCH002 flags
a > `MAX_PROPS` component / passes few props / no props; both no-op when
`ctx.components` unset.
- Console reporter shows an Architecture score line. Docs (en+ja), changeset.
- Full `pnpm -r test` + typecheck + lint + docs build green.
2 changes: 1 addition & 1 deletion packages/cli/src/providers/source/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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: [] };
return { file: rel, eachBlocks: [], effects: [], htmlTags: [], javascriptUrls: [], loc: 0, propCount: 0 };
}
})
);
Expand Down
47 changes: 44 additions & 3 deletions packages/cli/src/providers/source/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,21 +448,62 @@ function collectSecurityFacts(node: Node, source: string, htmlTags: SourceSpan[]
}
}

/** Parse a component's reactivity/correctness + security facts (CLI/static only). */
/** Whether a CallExpression is a bare `$props()` call. */
function isPropsCall(node: Node): boolean {
return node?.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === '$props';
}

/** Named props destructured from `$props()`, or 0 when unknowable (ARCH002). */
function countProps(program: Node): number {
let count = 0;
let seen = 0;
// Unknowable when: a non-destructured / `...rest` $props(), or more than one $props()
// call (a normal component has exactly one) — either way we can't trust a count.
let uncountable = false;
walkEstree(program, (n) => {
if (n.type !== 'VariableDeclarator' || !n.init || !isPropsCall(n.init)) return;
seen++;
const props = n.id?.type === 'ObjectPattern' ? n.id.properties : undefined;
if (!Array.isArray(props) || props.some((p: Node) => p?.type === 'RestElement')) {
uncountable = true;
return;
}
count = props.filter((p: Node) => p?.type === 'Property').length;
});
return uncountable || seen > 1 ? 0 : count;
}

/** Source line count, not over-counting a single trailing newline (ARCH001). */
function countLines(source: string): number {
if (source.length === 0) return 0;
return source.split('\n').length - (source.endsWith('\n') ? 1 : 0);
}

/** Parse a component's reactivity/correctness + security + architecture facts (CLI/static only). */
export function parseComponentFacts(
source: string,
filename: string
): { eachBlocks: EachBlockFact[]; effects: EffectFact[]; htmlTags: SourceSpan[]; javascriptUrls: SourceSpan[] } {
): {
eachBlocks: EachBlockFact[];
effects: EffectFact[];
htmlTags: SourceSpan[];
javascriptUrls: SourceSpan[];
loc: number;
propCount: number;
} {
const ast = parse(source, { modern: true, filename }) as Node;
const eachBlocks: EachBlockFact[] = [];
collectEachBlocks(ast.fragment ?? ast, source, eachBlocks);
const htmlTags: SourceSpan[] = [];
const javascriptUrls: SourceSpan[] = [];
collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
const loc = countLines(source);

const effects: EffectFact[] = [];
let propCount = 0;
const program = ast.instance?.content;
if (program) {
propCount = countProps(program);
const stateNames = new Set<string>();
walkEstree(program, (n) => {
if (n.type === 'VariableDeclarator' && n.init && isStateDeclaration(n.init) && n.id?.type === 'Identifier') {
Expand All @@ -479,5 +520,5 @@ export function parseComponentFacts(
});
});
}
return { eachBlocks, effects, htmlTags, javascriptUrls };
return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount };
}
8 changes: 3 additions & 5 deletions packages/cli/test/docs-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const enRules = join(repoRoot, 'docs', 'src', 'content', 'docs', 'rules');
const jaRules = join(repoRoot, 'docs', 'src', 'content', 'docs', 'ja', 'rules');

// Rules whose findings link to our own docs — every category has reference pages.
const documented = allRules.filter(
(r) =>
r.category === 'seo' || r.category === 'performance' || r.category === 'correctness' || r.category === 'security'
);
// Every rule links its findings to our own docs, so every category has reference pages.
const DOCUMENTED_CATEGORIES = new Set(['seo', 'performance', 'correctness', 'security', 'architecture']);
const documented = allRules.filter((r) => DOCUMENTED_CATEGORIES.has(r.category));

describe('docs: every documented rule has a reference page (en + ja)', () => {
it('has an en page per rule id (lowercased slug)', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/test/parse-component-facts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,31 @@ describe('parseComponentFacts — security (SEC001/SEC002)', () => {
});
});

describe('parseComponentFacts — architecture (ARCH001/ARCH002)', () => {
it('counts source lines (loc), not over-counting a trailing newline', () => {
expect(parseComponentFacts('<p>a</p>\n<p>b</p>\n<p>c</p>', 'C.svelte').loc).toBe(3);
expect(parseComponentFacts('<p>a</p>\n<p>b</p>\n<p>c</p>\n', 'C.svelte').loc).toBe(3);
});
it('counts destructured props from $props()', () => {
expect(parseComponentFacts('<script>let { a, b, c } = $props();</script>', 'C.svelte').propCount).toBe(3);
});
it('reports 0 props for a rest element or non-destructured $props()', () => {
expect(parseComponentFacts('<script>let { a, ...rest } = $props();</script>', 'C.svelte').propCount).toBe(0);
expect(parseComponentFacts('<script>let props = $props();</script>', 'C.svelte').propCount).toBe(0);
});
it('returns 0 when any $props() shape is uncountable (mixed patterns)', () => {
const src = '<script>let { a, b } = $props(); let other = $props();</script>';
expect(parseComponentFacts(src, 'C.svelte').propCount).toBe(0);
});
it('returns 0 when more than one $props() is destructured (ambiguous)', () => {
const src = '<script>let { a } = $props(); let { b, c } = $props();</script>';
expect(parseComponentFacts(src, 'C.svelte').propCount).toBe(0);
});
it('reports 0 props when there is no $props()', () => {
expect(parseComponentFacts('<p>hi</p>', 'C.svelte').propCount).toBe(0);
});
});

describe('collectComponentFacts (memory runtime)', () => {
it('scans every .svelte under src, including $lib', async () => {
const rt = createMemoryRuntime({
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface SourceSpan {
line: number;
}

/** Reactivity/correctness + security facts parsed from one `.svelte` component. */
/** Reactivity/correctness + security + architecture facts parsed from one `.svelte` component. */
export interface ComponentFacts {
/** Source file the component came from. */
file: string;
Expand All @@ -36,4 +36,8 @@ export interface ComponentFacts {
htmlTags: SourceSpan[];
/** Element attributes with a literal `javascript:` URL (Security SEC002). */
javascriptUrls: SourceSpan[];
/** Source line count of the component file (Architecture ARCH001). */
loc: number;
/** Named props destructured from `$props()`; 0 when unknowable (rest / non-destructured) (Architecture ARCH002). */
propCount: number;
}
4 changes: 3 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export {
correct001EachKey,
correct002EffectDerived,
sec001Html,
sec002JavascriptUrl
sec002JavascriptUrl,
arch001ComponentSize,
arch002PropCount
} from './rules/index.js';
export type { RuleInfo } from './rules/index.js';
export { headTagRule } from './rules/seo/head-tag-rule.js';
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/reporter/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ const CATEGORY_LABEL: Partial<Record<Category, string>> = {
seo: 'SEO',
performance: 'Performance',
correctness: 'Correctness',
security: 'Security'
security: 'Security',
architecture: 'Architecture'
};
const CATEGORY_ORDER: Category[] = ['seo', 'performance', 'correctness', 'security'];
const CATEGORY_ORDER: Category[] = ['seo', 'performance', 'correctness', 'security', 'architecture'];

export interface ConsoleReportOptions {
byRoute?: boolean;
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/rules/architecture/arch001-002.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { componentRule } from '../component-rule.js';

/** A component longer than this many lines is a "god component" smell. */
const MAX_LOC = 400;
/** More destructured props than this suggests the component is doing too much. */
const MAX_PROPS = 10;

export const arch001ComponentSize = componentRule({
id: 'ARCH001',
title: 'Component size',
category: 'architecture',
severity: 'info',
label: 'Component size',
recommendation: `Split components over ${MAX_LOC} lines into smaller, focused pieces.`,
rationale:
'A very large component is hard to read, test, and reuse, and is a common sign that several responsibilities should be split out.',
applies: (c) => c.loc > 0, // skip unanalyzable files (loc 0 = read/parse failure), don't PASS them
bad: (c) => (c.loc > MAX_LOC ? [{ line: 1, message: `Component is ${c.loc} lines (over ${MAX_LOC})` }] : [])
});

export const arch002PropCount = componentRule({
id: 'ARCH002',
title: 'Prop count',
category: 'architecture',
severity: 'info',
label: 'Prop count',
recommendation: `Group related props into an object, or split the component, when it takes more than ${MAX_PROPS} props.`,
rationale:
'A component taking many props is usually doing too much; grouping or splitting keeps its API understandable.',
applies: (c) => c.propCount > 0, // only components whose props we could count
bad: (c) =>
c.propCount > MAX_PROPS ? [{ line: 1, message: `Component takes ${c.propCount} props (over ${MAX_PROPS})` }] : []
});
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'>;
export type ComponentCategory = Extract<Category, 'correctness' | 'security' | 'architecture'>;

export interface ComponentRuleOptions {
id: string;
Expand Down
Loading