feat(F5): frontend component-extraction detector (dumb/smart)

New Pass B detector (report-only). F5a: ast-grep extracts JSX/template elements and
SKELETON-CLUSTERS repeated markup (cpd misses renamed markup) → judge → "extract <Name>
(dumb/smart), props {…}, used in N places". F5b: god component → smart/dumb split.
Classifies dumb vs smart by counting state/effect/store/fetch signals (per-framework).

Framework-agnostic: JSX/TSX native in ast-grep; Vue/Svelte/Angular via opt-in grammars
(sgconfig.frontend.yml; Vue grammar build documented, Svelte already shipped, Angular beta);
LLM-judge fallback classifies without a grammar. Validated: card found ×3 across React
fixtures (cpd found 0), Vue template parsed, 33 repeated-markup clusters on the real
software-house Svelte dashboard.

Adds references/COMPONENTS.md, sgconfig.frontend.yml, fixtures/frontend/{react,vue},
SKILL.md F5 wiring, ANTIPATTERNS rows, ledger kind:"component" + props/component_kind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-06-23 03:08:44 +02:00
co-authored by Claude Opus 4.8
parent 81b33fff26
commit cba4f44f45
9 changed files with 244 additions and 1 deletions
+9
View File
@@ -104,6 +104,14 @@ For Svelte `.svelte`, see `references/SVELTE.md` (project tooling / opt-in gramm
- *Reinventing-the-wheel* — `AST_GREP` function-signature index → name/signature similarity.
- *Lint (defer, don't reimplement)* — bundled `biome lint --reporter=json <paths>` (JS/TS) and `ruff check --output-format=json <paths>` (Python), read-only. Deeper/type-aware/other-language → optional project toolchain only-if-present.
- *Metrics* — `scc --by-file -f csv --no-cocomo <paths>` (+ ast-grep-derived nesting/length/params) → flag god/long functions.
- *Frontend component extraction (F5)* — `references/COMPONENTS.md`. **F5a**: ast-grep
extracts JSX/template elements (`kind: jsx_element`; Vue/Svelte/Angular via the opt-in
grammars in `sgconfig.frontend.yml`) → **skeleton-cluster** repeated markup (cpd alone
misses renamed markup) → judge → "extract `<Name>` (dumb/smart) used in N places, props
{…}". **F5b**: flag god components (large render + many state/effect hooks) → judge a
smart-container/dumb-child split. Classify dumb vs smart by counting state/effect/
store/fetch signals (per-framework table in COMPONENTS.md). Trigger words: component,
reuse, duplicated UI/markup, presentational/container, dumb/smart, extract component.
## Step 3 — Infer build / test / lint (for the report's `verify` fields)
@@ -156,6 +164,7 @@ Then stop. The skill's job is done; the agent acts on `findings.json` / the repo
- Detection rules & enrichment: `references/DETECTION.md` · Pattern catalog: `references/PATTERNS.md`
- Consolidation pipeline + judge contract + AHA guardrails: `references/CONSOLIDATION.md`
- Frontend component extraction (dumb/smart): `references/COMPONENTS.md`
- AI anti-pattern taxonomy → treatment: `references/ANTIPATTERNS.md`
- Token/model tactics: `references/OPTIMIZATION.md` · Per-runtime notes: `references/PORTABILITY.md`
- Svelte / SvelteKit: `references/SVELTE.md` · Bundled engines: `bin/manifest.json`
+3 -1
View File
@@ -37,7 +37,9 @@
"required": ["id", "kind", "detector", "title", "members", "severity", "confidence", "action", "verify", "status"],
"properties": {
"id": { "type": "string", "description": "stable id, e.g. c01 / f0001" },
"kind": { "enum": ["structural", "lint", "metric", "consolidation"], "description": "structural=Pass A rule; lint=Biome/ruff; metric=scc/derived; consolidation=Pass B" },
"kind": { "enum": ["structural", "lint", "metric", "consolidation", "component"], "description": "structural=Pass A rule; lint=Biome/ruff; metric=scc/derived; consolidation=Pass B dup/reuse; component=Pass B F5 frontend component extraction" },
"props": { "type": "array", "items": { "type": "string" }, "description": "component (F5) only: inferred props for the proposed component" },
"component_kind": { "enum": ["dumb", "smart", "split"], "description": "component (F5) only: presentational | container | recommended split" },
"title": { "type": "string", "description": "REQUIRED: one-line summary of the finding (what + where, human-scannable)" },
"action": { "type": "string", "description": "REQUIRED: the concrete, executable change the agent should make (not a vague suggestion). The skill itself never performs it." },
"verify": { "type": "string", "description": "REQUIRED: command(s) to confirm the change after the agent applies it (usually config.build/test/typecheck)" },
+18
View File
@@ -0,0 +1,18 @@
// Same card markup duplicated here (cross-file F5a candidate → extract a dumb <UserCard>).
type Member = { id: string; name: string; email: string; avatar: string };
export function Dashboard({ members }: { members: Member[] }) {
return (
<section className="grid">
{members.map((m) => (
<div key={m.id} className="card">
<img className="card-avatar" src={m.avatar} alt={m.name} />
<div className="card-body">
<h3 className="card-title">{m.name}</h3>
<p className="card-subtitle">{m.email}</p>
</div>
</div>
))}
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
// A god component (F5b candidate): owns state + effects + data + lots of inline markup.
// Should split into a smart <SettingsContainer> + dumb children (incl. the duplicated card).
import { useState, useEffect } from "react";
type Person = { id: string; name: string; email: string; avatar: string };
export function Settings() {
const [people, setPeople] = useState<Person[]>([]);
const [query, setQuery] = useState("");
const [tab, setTab] = useState<"members" | "billing">("members");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/people").then((r) => r.json()).then(setPeople).catch((e) => setError(String(e)));
}, []);
useEffect(() => {
document.title = `Settings (${people.length})`;
}, [people]);
const save = async () => {
setSaving(true);
try {
await fetch("/api/save", { method: "POST", body: JSON.stringify({ people }) });
} finally {
setSaving(false);
}
};
const filtered = people.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()));
return (
<div className="settings">
<header className="settings-header">
<input className="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<nav className="tabs">
<button className={tab === "members" ? "active" : ""} onClick={() => setTab("members")}>Members</button>
<button className={tab === "billing" ? "active" : ""} onClick={() => setTab("billing")}>Billing</button>
</nav>
<button disabled={saving} onClick={save}>{saving ? "Saving…" : "Save"}</button>
</header>
{error && <div className="error">{error}</div>}
{tab === "members" && (
<div className="grid">
{filtered.map((p) => (
<div key={p.id} className="card">
<img className="card-avatar" src={p.avatar} alt={p.name} />
<div className="card-body">
<h3 className="card-title">{p.name}</h3>
<p className="card-subtitle">{p.email}</p>
</div>
</div>
))}
</div>
)}
{tab === "billing" && <div className="billing">Billing settings</div>}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
// Intentionally duplicated card markup (F5a candidate).
import { useState, useEffect } from "react";
type User = { id: string; name: string; email: string; avatar: string };
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetch("/api/users").then((r) => r.json()).then(setUsers);
}, []);
return (
<div className="grid">
{users.map((u) => (
<div key={u.id} className="card">
<img className="card-avatar" src={u.avatar} alt={u.name} />
<div className="card-body">
<h3 className="card-title">{u.name}</h3>
<p className="card-subtitle">{u.email}</p>
</div>
</div>
))}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
// Vue fixture with duplicated card markup (F5a candidate, cross-framework discovery).
defineProps<{ users: { id: string; name: string; email: string; avatar: string }[] }>();
</script>
<template>
<div class="grid">
<div v-for="u in users" :key="u.id" class="card">
<img class="card-avatar" :src="u.avatar" :alt="u.name" />
<div class="card-body">
<h3 class="card-title">{{ u.name }}</h3>
<p class="card-subtitle">{{ u.email }}</p>
</div>
</div>
</div>
</template>
+2
View File
@@ -18,6 +18,8 @@ not auto-fixed) · **SKIP** (out of scope).
| **Near-duplicate functions** (Type-2/3, parametrizable) | cpd + ast-grep skeleton + judge | **BUILD** F1 |
| **Untyped repeated object shapes** | extract+cluster+judge | **BUILD** F1 (conservative) |
| **Reinventing existing utilities** (no reuse) | signature index + judge | **BUILD** F2 |
| **Duplicated frontend markup → shared component** | JSX/template skeleton cluster + judge | **BUILD** F5a (REPORT) |
| **God component → smart/dumb split** | size + state/effect-hook count + judge | **BUILD** F5b (REPORT) |
| God/long functions, high cyclomatic | scc + ast-grep metrics | **BUILD** F4 (REPORT) |
| Explicit `any`, unsafe casts | linter | LINT (Biome) / ENRICH (tsc) |
| Non-null `!`, useless catch, `no-console` | linter | LINT (Biome) |
+72
View File
@@ -0,0 +1,72 @@
# F5 — Frontend component extraction (dumb/smart)
A Pass B detector that surfaces **reusable frontend code that should become a component** and
classifies the target as **dumb (presentational)** or **smart (container)**. Report-only —
it proposes; the acting agent extracts. Framework-agnostic (React/Solid/Preact JSX, Vue,
Svelte, Angular). Pipeline + judge contract + output contract as in `CONSOLIDATION.md`.
## Definitions (classification aid, not dogma — Abramov retired the strict split in 2019)
- **Dumb / presentational** — props in → markup out. No state, effects, data-fetching,
store/context/DI, or routing. Pure render + callback props.
- **Smart / container** — owns state/effects, fetches data, reads stores/context/DI,
orchestrates children.
## Two detectors
### F5a — duplicated markup → extract a shared component
The workhorse is **ast-grep JSX/element-skeleton clustering** (cpd alone misses renamed
markup, exactly like near-duplicate functions):
1. **Extract** element subtrees: `ast-grep scan --inline-rules 'language: tsx\nrule: {kind: jsx_element}'`
(Vue/Svelte/Angular: `kind: element` via the opt-in grammars in `sgconfig.frontend.yml`).
2. **Normalize to a skeleton**: replace JSX expressions `{…}``{E}` and attribute string
values `"…"``"S"`, strip whitespace. Drop trivial subtrees (skeleton < ~80 chars).
3. **Cluster** by identical skeleton → repeated markup structures, even with renamed
bindings (validated on fixtures: the `<div className="card">…</div>` card found ×3 across
files where cpd found 0).
4. Optionally corroborate exact copy-paste with `cpd` (secondary, 0 tokens).
5. **Judge** each cluster (below) → finding: extract `<Name>` (dumb) used in N places.
### F5b — god component → split into smart container + dumb child(ren)
1. **Size/complexity signals** (ast-grep + scc): large render/return block; high `jsx_element`
count; many state/effect hooks in one component; far above the repo norm.
2. **Judge** proposes a split: keep state/data in a smart container; extract the pure markup
into a dumb child with an explicit props interface (often the F5a card itself).
## Dumb/smart classification — AST signals (count via ast-grep; judge confirms)
A component/fragment is **smart** if it contains any of these; **dumb** if none (props +
callbacks + markup only). All are AST-detectable (validated: `useState($$$)`/`useEffect($$$)`
counts cleanly separated the fixtures).
| Framework | Smart signals | Dumb signals |
|---|---|---|
| React/Solid/Preact | `useState/useReducer/useEffect/useContext/useRef`(+effect); `createSignal/createEffect/createResource`; store hooks (`useSelector/useStore`); fetch/axios/react-query/swr | props params only; callback props (`onX`); pure JSX |
| Vue | `ref/reactive/computed/watch/watchEffect`; `onMounted…`; Pinia `useStore`; `inject`; async in setup | `defineProps`/`defineEmits` + template |
| Svelte | `$state/$derived/$effect` (or Svelte4 `let`+`$:`); `$store`; `onMount`; `load` | `$props()`/`export let` + callback props + markup |
| Angular | injected services/`inject()`; `HttpClient`; observables/signals; lifecycle w/ service calls | `@Input/@Output`-only; `ChangeDetectionStrategy.OnPush` |
## Judge contract (extends CONSOLIDATION.md)
Give the model the candidate spans; it returns:
```json
{ "is_component": true, "name": "UserCard", "props": ["name","email","avatar"],
"kind": "dumb | smart | split", "verdict": "extract | split | keep-separate",
"confidence": 0.0, "why": "...", "action": "...", "verify": "..." }
```
Include only `verdict != keep-separate` with `confidence ≥ 0.6`. The `action` is concrete
(create `<path>` with the props interface; replace the N sites) and `verify` is the project
build/test/typecheck (+ `svelte-check`/`tsc` where relevant).
## Guardrails (AHA + framework idioms)
- **Rule of three** — don't extract a 2-instance or trivial fragment (e.g. a lone `<div>`).
- **Don't break idioms** — preserve list `key`s, slots/`children`, named slots, SSR/hydration,
framework event naming; a dumb child must receive what it needs via props/slots.
- **Stateful duplicates** → recommend a **dumb child + lifted state** (don't duplicate the state).
- **Keep intentionally-divergent UI separate** — similar-looking markup with different
semantics across bounded contexts (marketing vs app) is not necessarily one component.
- **Report-only** — never create files or edit; the finding tells the agent how.
## Cross-framework parsing
- **JSX/TSX** — native in bundled `ast-grep` (`jsx_element`, `jsx_self_closing_element`).
- **Vue/Svelte/Angular** — opt-in grammars via `sgconfig.frontend.yml` (build like the Svelte
one; see that file). Without a grammar, `cpd` discovery + the LLM judge reading the file
still classify — coverage stays framework-agnostic, grammars only add precision.
- **Discovery extensions** for cpd/scan: `.jsx,.tsx,.vue,.svelte,.html`, Angular `*.component.html`.
+39
View File
@@ -0,0 +1,39 @@
# Frontend-enabled ast-grep config — OPT-IN (for F5 component extraction on Vue/Svelte/Angular).
#
# JSX/TSX needs NO config (built into ast-grep). This config adds the non-built-in
# frameworks. Build each grammar once (needs only a C compiler; grammars ship prebuilt
# parser.c) into grammars/<lang>.<ext>:
# git clone --depth 1 https://github.com/tree-sitter-grammars/tree-sitter-vue /tmp/tsv
# cc -shared -fPIC -O2 -I /tmp/tsv/src /tmp/tsv/src/parser.c /tmp/tsv/src/scanner.c -o grammars/vue.so
# # svelte: see sgconfig.svelte.yml ; angular: https://github.com/dlvandenberg/tree-sitter-angular (beta)
# Then: ast-grep scan -c sgconfig.frontend.yml --inline-rules '<rule>' <paths>
# Without a grammar, F5 still works via cpd discovery + the LLM judge reading the file.
ruleDirs:
- rules
customLanguages:
vue:
libraryPath: ./grammars/vue.so
extensions: [vue]
expandoChar: _
svelte:
libraryPath: ./grammars/svelte.so
extensions: [svelte]
expandoChar: _
# angular:
# libraryPath: ./grammars/angular.so # tree-sitter-angular is beta; build if needed
# extensions: [component.html]
languageInjections:
- hostLanguage: vue
rule: { pattern: '<script setup lang="ts">$CONTENT</script>' }
injected: typescript
- hostLanguage: vue
rule: { pattern: '<script setup>$CONTENT</script>' }
injected: javascript
- hostLanguage: svelte
rule: { pattern: '<script lang="ts">$CONTENT</script>' }
injected: typescript
- hostLanguage: svelte
rule: { pattern: "<script>$CONTENT</script>" }
injected: javascript