From acde9622d4ad16ce9c6ab9e4827b0e8869c4784a Mon Sep 17 00:00:00 2001 From: oekazuma Date: Thu, 2 Jul 2026 16:57:53 +0900 Subject: [PATCH 1/9] docs: design spec for CORRECT004 (unmutated $state -> const) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...07-02-correct004-unmutated-state-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md diff --git a/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md b/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md new file mode 100644 index 000000000..2b2bd8ffe --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md @@ -0,0 +1,149 @@ +# CORRECT004 — unmutated `$state` → `const` + +**Date:** 2026-07-02 +**Status:** Approved design +**Packages:** `@svelte-vitals/core` (rule), `@svelte-vitals/cli` (capture), `@svelte-vitals/mcp` (surfaces the rule) + +## Goal + +Add **CORRECT004**, a further "More Correctness/reactivity" rule from #69. Flag a +`let x = $state(...)` declaration whose value is **never written or escaped** +anywhere in the component — the reactivity is unused, so `const` (or `$state.raw` +if only reassigned wholesale) is clearer and cheaper. `info` severity. + +Scope is **Smell A only** (never written → `const`). Smell B (reassigned but never +deep-mutated → `$state.raw`) is out of scope (its detection needs to distinguish +reassignment from deep mutation; a possible follow-up). + +No overlap with official tooling: the Svelte compiler and `svelte-check` do not +warn about a never-mutated `$state`. + +## Background / current state + +- `packages/cli/src/providers/source/parse.ts` tracks `stateNames` (via + `isStateDeclaration`, `$state`/`$state.raw`/`$state.frozen`) but does not track + writes. It already walks the instance ESTree (effects, props) and the template + fragment (`collectEachBlocks`, `collectSecurityFacts`). +- Rules use the `componentRule` factory (CLI/static only; no-ops in rendered + mode). CORRECT001/002/003 live in `packages/core/src/rules/correctness/`. +- Verified AST shapes: `bind:value={x}` → `BindDirective { expression: Identifier }`; + `` → `Component` node whose `attributes` hold the prop expressions, + and whose `fragment` holds slot children (rendered in the parent scope); + `{x}` → `ExpressionTag`. + +## Design + +### 1. What marks a `$state` as "written or escaped" (conservative — no false positives) + +A declared `$state` name is **suppressed** (not flagged) when, anywhere in the +component, it is: + +**Script (instance ESTree walk):** + +1. Reassigned / compound-assigned / updated — `AssignmentExpression` whose `left` + is an `Identifier` that is a state name, or an `UpdateExpression` (`x++`/`x--`) + on one. +2. Member/element assigned — `AssignmentExpression` whose `left` is a + `MemberExpression` whose **root object** identifier is a state name + (`x.a = …`, `x[i] = …`, `x.a.b = …`). +3. The object of a method call — `CallExpression` whose `callee` is a + `MemberExpression` whose root object is a state name (`x.push()`, `x.foo()`). +4. An argument to any call — `CallExpression` with an argument that is an + `Identifier` state name (`f(x)`, `Object.assign(x, …)`). + +**Template (Svelte AST walk):** + +5. Bound — a `BindDirective` whose expression's root identifier is a state name + (``, ``). **Required** — a bound + state is genuinely writable; missing this would be a false positive. +6. Passed as a component prop — a `Component` node with, among its **own + `attributes`** (an `Attribute` expression value or a `SpreadAttribute`), a state + name identifier (``, ``). A Component's slotted + children (its `fragment`) are **reads** in the parent scope and do **not** + suppress (`{x}` is a read). + +Everything else is a read and does **not** suppress: `{x}` interpolation, member +reads `x.a`, DOM-element attribute expressions (``), `{#each x}` +/ `{#if x}` expressions, and reads inside `$derived`/`$effect`. + +A `$state` declaration flagged (const candidate) is one whose name is in none of +1–6. Shadowing (a nested local reusing a state name that is written) conservatively +suppresses (a false negative, never a false positive). + +Only `let x = $state(...)` with an `Identifier` binding is considered; destructured +`$state` declarations are ignored (rare; the binding is not the state cell). + +### 2. Capture model — `ComponentFacts.constableStates` + +Add a focused field: + +```ts +/** `$state` declarations never written or escaped in the component — candidates for const (CORRECT004). */ +constableStates: { name: string; line: number }[]; +``` + +`parse.ts` changes (in the instance block, where `stateNames` is already built): + +- Collect `stateDecls: { name: string; line: number }[]` for each + `VariableDeclarator` with an `Identifier` id whose init `isStateDeclaration`. +- Build `writtenOrEscaped: Set` by walking the instance program (rules + 1–4) and the template fragment (rules 5–6). Helpers: + `rootObjectName(memberExpr)` → the base identifier name; a template walk that + tracks `BindDirective` expressions and `Component` `attributes`. +- `constableStates = stateDecls.filter((d) => !writtenOrEscaped.has(d.name))`. + +`stateNames` / `assignsOnlyState` / `reactiveNames` (CORRECT002/003) are unchanged. + +### 3. Rule — CORRECT004 + +Add to `packages/core/src/rules/correctness/` (new `correct004-unmutated-state.ts`, +or appended to the correctness rules file), via `componentRule`: + +- `id: 'CORRECT004'`, `title: 'Unmutated $state'`, `category: 'correctness'`, + `severity: 'info'`, `scope: 'component'`. +- `label` (PASS): `'$state usage'`. +- `recommendation`: `"If a value never changes, use const; if you only ever reassign it wholesale (never mutate its properties), use $state.raw to skip deep proxying."` +- `rationale`: `'A $state that is never mutated pays for reactivity (deep proxying, tracking) it never uses; const (or $state.raw) is clearer and cheaper.'` +- `applies`: `(c) => c.constableStates.length > 0`. +- `bad`: `(c) => c.constableStates.map((s) => ({ line: s.line, message: `$state "${s.name}" is never mutated — use const (or $state.raw if you only reassign it)` }))`. + +### 4. Registration & surfaces + +- Export `correct004UnmutatedState`, import + append to `allRules`, add to the + re-export blocks in `packages/core/src/rules/index.ts` and + `packages/core/src/index.ts` (after `correct003EffectAsOnMount`). +- MCP surfaces it automatically via `allRules`. + +### 5. Docs + +Two reference pages following the CORRECT003 format (title; `**Severity:** info · +**Category:** correctness`; What it checks / Why it matters / How to fix): + +- `docs/src/content/docs/rules/correct004.md` +- `docs/src/content/docs/ja/rules/correct004.md` + +### 6. Changeset + +`@svelte-vitals/core`, `svelte-vitals`, `@svelte-vitals/mcp` — **minor** (CLI/static +rule; not `@svelte-vitals/vite`). + +## Testing + +- **Capture** (`packages/cli` `parse-component-facts` tests): a `$state` read only + via interpolation / member read is `constable`; a `$state` that is reassigned, + compound-assigned, `++`/`--`, member-assigned (`x.a=`), method-called + (`x.push()`), passed as a call arg (`f(x)`), bound (`bind:value={x}`), or passed + as a component prop (``) is **not** constable; a Component's slot + child read (`{x}`) stays constable; a DOM attribute read + (``) stays constable. +- **Rule** (`packages/core` `correctness-rules` tests): a component with a + constable state fails (one finding per state, with line); no constable states → + no-signal (`applies` false). +- Full suite + typecheck + lint + `docs build` green; no assertions loosened. + +## Out of scope (YAGNI) + +- Smell B (`$state.raw` for reassigned-but-not-deep-mutated) as a separate signal. +- Destructured `$state` declarations. +- Cross-function/whole-program mutation tracking (bare-call and method-call + escapes are uniformly suppressed instead — conservative). From 90904b214fb2470fe52e297cd29371d062441672 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Thu, 2 Jul 2026 17:13:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20CORRECT004=20spec=20=E2=80=94=20det?= =?UTF-8?q?ect=20writes=20in=20template=20handler=20expressions=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...07-02-correct004-unmutated-state-design.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md b/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md index 2b2bd8ffe..ac4a90e18 100644 --- a/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md +++ b/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md @@ -38,7 +38,11 @@ warn about a never-mutated `$state`. A declared `$state` name is **suppressed** (not flagged) when, anywhere in the component, it is: -**Script (instance ESTree walk):** +**Script AND template expressions (ESTree walk over the instance program *and* +the template fragment):** — writes 1–4 are detected in both places, because a +`$state` is commonly mutated in an inline event handler (`')).toEqual([]); + }); + it('does not flag a bound $state', () => { + expect(names('')).toEqual([]); + }); + it('does not flag a $state passed as a component prop', () => { + expect(names('')).toEqual([]); + }); + it('still flags a $state only read in a slot child or DOM attribute', () => { + expect(names('{label}')).toEqual(['label']); + expect(names('')).toEqual(['ph']); + }); +}); +``` + +- [ ] **Step 3: Run the capture tests to verify they fail** + +Run: `pnpm --filter svelte-vitals test parse-component-facts` +Expected: FAIL — `constableStates` is `undefined`. + +- [ ] **Step 4: Add the parser helpers** + +In `packages/cli/src/providers/source/parse.ts`, immediately after the `addBoundNames` function, add: + +```ts +/** The base identifier name of a (possibly nested) member expression or identifier, else undefined. */ +function rootObjectName(node: Node): string | undefined { + let cur = node; + while (cur?.type === 'MemberExpression') cur = cur.object; + return cur?.type === 'Identifier' ? cur.name : undefined; +} + +/** + * Add state names that are WRITTEN or ESCAPED (CORRECT004 rules 1–4): reassignment, + * update, member/element assignment, method call on the state, or the state passed + * as a call argument. Run over the instance program AND the template fragment + * (inline handlers mutate state in the template). + */ +function collectStateWrites(root: Node, stateNames: Set, acc: Set): void { + walkEstree(root, (n: Node) => { + if (n?.type === 'AssignmentExpression') { + if (n.left?.type === 'Identifier' && stateNames.has(n.left.name)) acc.add(n.left.name); + else if (n.left?.type === 'MemberExpression') { + const r = rootObjectName(n.left); + if (r && stateNames.has(r)) acc.add(r); + } + } else if (n?.type === 'UpdateExpression' && n.argument?.type === 'Identifier' && stateNames.has(n.argument.name)) { + acc.add(n.argument.name); + } else if (n?.type === 'CallExpression') { + if (n.callee?.type === 'MemberExpression') { + const r = rootObjectName(n.callee); + if (r && stateNames.has(r)) acc.add(r); // x.push(), x.foo() + } + for (const a of n.arguments ?? []) { + if (a?.type === 'Identifier' && stateNames.has(a.name)) acc.add(a.name); // f(x) + } + } + }); +} + +/** + * Add state names ESCAPED via the template (CORRECT004 rules 5–6): a `bind:` on any + * element, or passed as a `Component` prop. Slot children / DOM-attribute reads do + * not escape. `CHILD_NODE_KEYS` omits `attributes`, so inspect them explicitly. + */ +function collectTemplateEscapes(node: Node, stateNames: Set, acc: Set): void { + if (Array.isArray(node)) { + for (const c of node) collectTemplateEscapes(c, stateNames, acc); + return; + } + if (!node || typeof node !== 'object' || typeof node.type !== 'string') return; + if (Array.isArray(node.attributes)) { + for (const attr of node.attributes) { + if (attr?.type === 'BindDirective') { + const r = rootObjectName(attr.expression); + if (r && stateNames.has(r)) acc.add(r); + } else if (node.type === 'Component') { + walkEstree(attr, (m: Node) => { + if (m?.type === 'Identifier' && stateNames.has(m.name)) acc.add(m.name); + }); + } + } + } + for (const key of CHILD_NODE_KEYS) { + if (key in node) collectTemplateEscapes(node[key], stateNames, acc); + } +} +``` + +- [ ] **Step 5: Declare `constableStates` and collect state declarations** + +In `parseComponentFacts`, add `constableStates` to the return-type annotation, after `namespaceImports: { source: string; line: number }[];`: + +```ts + constableStates: { name: string; line: number }[]; +``` + +Add a `let` binding next to `const effects` (before the `if (program)` block), so it defaults to empty when there is no instance script. Change: + +```ts + const effects: EffectFact[] = []; + let propCount = 0; +``` + +to: + +```ts + const effects: EffectFact[] = []; + const constableStates: { name: string; line: number }[] = []; + let propCount = 0; +``` + +In the instance block, extend the existing `VariableDeclarator` walk to also record state declarations (name + line). Change: + +```ts + walkEstree(program, (n) => { + if (n.type !== 'VariableDeclarator' || !n.init) return; + if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') stateNames.add(n.id.name); + if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init)) + addBoundNames(n.id, reactiveNames); + }); +``` + +to: + +```ts + const stateDecls: { name: string; line: number }[] = []; + walkEstree(program, (n) => { + if (n.type !== 'VariableDeclarator' || !n.init) return; + if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') { + stateNames.add(n.id.name); + stateDecls.push({ name: n.id.name, line: lineOf(source, n.start) }); + } + if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init)) + addBoundNames(n.id, reactiveNames); + }); +``` + +- [ ] **Step 6: Compute `constableStates`** + +Still in the instance block, after the effects-collecting `walkEstree(...)` call (the one that pushes to `effects`), add: + +```ts + const writtenOrEscaped = new Set(); + collectStateWrites(program, stateNames, writtenOrEscaped); + if (ast.fragment) { + collectStateWrites(ast.fragment, stateNames, writtenOrEscaped); + collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped); + } + for (const d of stateDecls) { + if (!writtenOrEscaped.has(d.name)) constableStates.push(d); + } +``` + +- [ ] **Step 7: Return `constableStates`** + +Change the return statement: + +```ts + return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports }; +``` + +to: + +```ts + return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports, constableStates }; +``` + +- [ ] **Step 8: Update the provider catch fallback and core test helpers** + +The fallback literal in `packages/cli/src/providers/source/components.ts` (the `catch` block) and the ComponentFacts helpers in the four core test files each need `constableStates: []`: + +- `packages/cli/src/providers/source/components.ts` — in the catch block's returned object, after `namespaceImports: []`, add `constableStates: []`. +- `packages/core/test/correctness-rules.test.ts` — in the `comp(over)` base object, after `namespaceImports: [],` add ` constableStates: [],`. +- `packages/core/test/security-rules.test.ts` — same base helper: add `constableStates: []`. +- `packages/core/test/architecture-rules.test.ts` — same: add `constableStates: []`. +- `packages/core/test/bundle-rules.test.ts` — in the `comp()` helper: add `constableStates: []`. + +- [ ] **Step 9: Run capture tests + typecheck + affected core suites** + +Run: `pnpm --filter svelte-vitals test parse-component-facts` +Expected: PASS (existing + new). +Run: `pnpm --filter @svelte-vitals/core build && pnpm --filter @svelte-vitals/core typecheck && pnpm --filter svelte-vitals typecheck` +Expected: no errors. (Core rebuilt first — `ComponentFacts` changed; cli consumes core's dist.) +Run: `pnpm --filter @svelte-vitals/core test` +Expected: PASS (the helper edits keep all core rule suites green). + +- [ ] **Step 10: Commit** + +```bash +git add packages/core/src/component.ts packages/cli/src/providers/source/parse.ts packages/cli/src/providers/source/components.ts packages/cli/test/parse-component-facts.test.ts packages/core/test/correctness-rules.test.ts packages/core/test/security-rules.test.ts packages/core/test/architecture-rules.test.ts packages/core/test/bundle-rules.test.ts +git commit -m "feat(cli): capture constableStates for CORRECT004" +``` + +--- + +### Task 2: CORRECT004 rule + registration + +**Files:** +- Create: `packages/core/src/rules/correctness/correct004-unmutated-state.ts` +- Modify: `packages/core/src/rules/index.ts` (import ~line 40; `allRules`; re-export) +- Modify: `packages/core/src/index.ts` (re-export after `correct003EffectAsOnMount`) +- Test: `packages/core/test/correctness-rules.test.ts` (add CORRECT004 describe block) + +**Interfaces:** +- Consumes: `componentRule`; `ComponentFacts.constableStates` (Task 1). +- Produces: `export const correct004UnmutatedState: Rule`. + +- [ ] **Step 1: Write the failing rule tests** + +In `packages/core/test/correctness-rules.test.ts`, add `correct004UnmutatedState` to the `../src/index.js` import, then append: + +```ts +describe('CORRECT004 unmutated $state', () => { + it('flags a constable $state (one finding per state, with line)', async () => { + const rs = await correct004UnmutatedState.check( + ctx([comp({ constableStates: [{ name: 'title', line: 2 }] })]) + ); + expect(fails(rs)).toHaveLength(1); + expect(rs[0]!.category).toBe('correctness'); + expect(rs[0]!.line).toBe(2); + expect(rs[0]!.message).toContain('title'); + }); + it('reports one finding per distinct constable state', async () => { + const rs = await correct004UnmutatedState.check( + ctx([comp({ constableStates: [{ name: 'a', line: 2 }, { name: 'b', line: 3 }] })]) + ); + expect(fails(rs)).toHaveLength(2); + }); + it('is no-signal when there are no constable states', async () => { + const rs = await correct004UnmutatedState.check(ctx([comp({ constableStates: [] })])); + expect(rs).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm --filter @svelte-vitals/core test correctness-rules` +Expected: FAIL — `correct004UnmutatedState` is not exported. + +- [ ] **Step 3: Write the rule** + +Create `packages/core/src/rules/correctness/correct004-unmutated-state.ts`: + +```ts +import { componentRule } from '../component-rule.js'; + +export const correct004UnmutatedState = componentRule({ + id: 'CORRECT004', + title: 'Unmutated $state', + category: 'correctness', + severity: 'info', + label: '$state usage', + recommendation: + 'If a value never changes, use const; if you only ever reassign it wholesale (never mutate its properties), use $state.raw to skip deep proxying.', + rationale: + 'A $state that is never mutated pays for reactivity (deep proxying, tracking) it never uses; const (or $state.raw) is clearer and cheaper.', + applies: (c) => c.constableStates.length > 0, + bad: (c) => + c.constableStates.map((s) => ({ + line: s.line, + message: `$state "${s.name}" is never mutated — use const (or $state.raw if you only reassign it)` + })) +}); +``` + +- [ ] **Step 4: Register the rule** + +In `packages/core/src/rules/index.ts`: + +- CORRECT004 lives in its own file, so add a SEPARATE import line immediately after the existing correctness import (the one importing `correct001EachKey`, `correct002EffectDerived`, `correct003EffectAsOnMount`): + `import { correct004UnmutatedState } from './correctness/correct004-unmutated-state.js';` +- In `allRules`, replace the ` correct003EffectAsOnMount,` line with: + ```ts + correct003EffectAsOnMount, + correct004UnmutatedState, + ``` +- In the re-export `export { … }` block, replace the ` correct003EffectAsOnMount,` line with the same two lines. + +In `packages/core/src/index.ts`, replace the ` correct003EffectAsOnMount,` line with: + +```ts + correct003EffectAsOnMount, + correct004UnmutatedState, +``` + +- [ ] **Step 5: Run rule tests + typecheck** + +Run: `pnpm --filter @svelte-vitals/core test correctness-rules` +Expected: PASS (CORRECT001/002/003 + 3 new CORRECT004). +Run: `pnpm --filter @svelte-vitals/core typecheck` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/rules/correctness/correct004-unmutated-state.ts packages/core/src/rules/index.ts packages/core/src/index.ts packages/core/test/correctness-rules.test.ts +git commit -m "feat(core): add CORRECT004 unmutated-\$state rule" +``` + +--- + +### Task 3: Docs + changeset + +**Files:** +- Create: `docs/src/content/docs/rules/correct004.md`, `docs/src/content/docs/ja/rules/correct004.md` +- Create: `.changeset/correct004-unmutated-state.md` + +- [ ] **Step 1: Write the English doc** + +Create `docs/src/content/docs/rules/correct004.md`: + +```md +--- +title: CORRECT004 · Unmutated $state +description: Use const (or $state.raw) for a $state that is never mutated. +--- + +**Severity:** info · **Category:** correctness + +## What it checks + +Flags a `let x = $state(...)` whose value is never written or escaped anywhere in the component — not reassigned, not mutated (`x.a = …`, `x.push()`), not bound (`bind:value={x}`), not passed to a function or component. Checked by static (CLI) analysis of the component script and template. + +## Why it matters + +A `$state` that is never mutated pays for reactivity — deep proxying and dependency tracking — that it never uses. `const` is clearer and cheaper; `$state.raw` fits when you only ever reassign the value wholesale (never mutate its properties). + +## How to fix + +```svelte + +``` +``` + +- [ ] **Step 2: Write the Japanese doc** + +Create `docs/src/content/docs/ja/rules/correct004.md`: + +```md +--- +title: CORRECT004 · 変更されない $state +description: 変更されない $state には const(または $state.raw)を使います。 +--- + +**重大度:** info · **カテゴリ:** correctness + +## チェック内容 + +コンポーネント内のどこでも書き込み・エスケープされない `let x = $state(...)` を検出します — 再代入なし、変更なし(`x.a = …`、`x.push()`)、バインドなし(`bind:value={x}`)、関数やコンポーネントへの受け渡しなしのものです。コンポーネントのスクリプトとテンプレートを静的(CLI)解析します。 + +## なぜ重要か + +変更されない `$state` は、使わないリアクティビティ(deep proxy と依存追跡)のコストを払っています。`const` の方が明確で軽量です。値をまるごと差し替えるだけ(プロパティは変更しない)なら `$state.raw` が適します。 + +## 修正方法 + +```svelte + +``` +``` + +- [ ] **Step 3: Write the changeset** + +Create `.changeset/correct004-unmutated-state.md`: + +```md +--- +'@svelte-vitals/core': minor +'svelte-vitals': minor +'@svelte-vitals/mcp': minor +--- + +Add **CORRECT004 (unmutated $state)** — a Correctness/reactivity rule from #69. +Flags a `let x = $state(...)` that is never written or escaped anywhere in the +component (no reassignment, member/method mutation, bind, call-arg, or +component-prop pass), so its reactivity is unused — use `const` (or `$state.raw` +if only reassigned wholesale). Reported under `correctness` (info). `ComponentFacts` +gains `constableStates`. +``` + +- [ ] **Step 4: Verify docs build** + +Run: `pnpm --filter docs build` +Expected: build succeeds; page count rises by 2 (both correct004 pages present). + +- [ ] **Step 5: Commit** + +```bash +git add docs/src/content/docs/rules/correct004.md docs/src/content/docs/ja/rules/correct004.md .changeset/correct004-unmutated-state.md +git commit -m "docs: CORRECT004 reference pages (en+ja) + changeset" +``` + +--- + +### Task 4: Full verification + +**Files:** none (verification only). + +- [ ] **Step 1: Build core, then run the whole suite / typecheck / lint / docs build** + +Run: +```bash +pnpm -r build && pnpm -r test && pnpm -r typecheck && pnpm lint && pnpm --filter docs build +``` +Expected: all green. Core test count rises by 3 (CORRECT004 rule tests); cli by ~6 (constable capture tests). + +- [ ] **Step 2: If lint reports formatting, fix and re-run** + +Run: `pnpm exec prettier --write . && pnpm lint` +Expected: "All matched files use Prettier code style!" and eslint clean. + +- [ ] **Step 3: Final commit (only if Step 2 changed files)** + +```bash +git add -A +git commit -m "chore: format CORRECT004 changes" +``` + +--- + +## Self-Review + +**Spec coverage:** +- `ComponentFacts.constableStates` field → Task 1 Step 1. ✓ +- Write detection (assign/update/member/method/call-arg) over script + template → `collectStateWrites` run on program AND fragment, Task 1 Steps 4, 6. ✓ +- Template escapes (bind: + component prop; slot/DOM-attr reads excluded) → `collectTemplateEscapes`, Task 1 Step 4. ✓ +- Handler-mutation false-positive guard → fragment walk in Step 6, tested in Step 2. ✓ +- state declarations (Identifier only) + line → Task 1 Step 5. ✓ +- Provider fallback + core helper compile fixups → Task 1 Step 8. ✓ +- CORRECT004 rule (info/correctness/component, one finding per state) → Task 2 Step 3. ✓ +- Registration in allRules + both re-exports; MCP via allRules → Task 2 Step 4. ✓ +- Docs 2 pages + changeset (core/svelte-vitals/mcp minor) → Task 3. ✓ +- Testing matrix (read-only / script-write / handler / bind / component-prop / slot-read / DOM-attr-read; rule flag / multi / no-signal) → Tasks 1-2. ✓ +- Out of scope (Smell B, destructured $state, cross-function tracking) → not planned. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows full code. ✓ + +**Type consistency:** `constableStates: { name: string; line: number }[]` identical in `component.ts`, `parse.ts` return type, and rule consumption. `collectStateWrites`/`collectTemplateEscapes`/`rootObjectName` defined once (Task 1 Step 4) and used in Step 6. Rule name `correct004UnmutatedState` consistent across Task 2 and tests. Registration uses a separate import line for the new file (Task 2 Step 4). ✓ From 722e6f4d82b95ea9155f09a35ee986e76a52b06f Mon Sep 17 00:00:00 2001 From: oekazuma Date: Thu, 2 Jul 2026 17:19:15 +0900 Subject: [PATCH 4/9] feat(cli): capture constableStates for CORRECT004 --- .../cli/src/providers/source/components.ts | 3 +- packages/cli/src/providers/source/parse.ts | 82 ++++++++++++++++++- .../cli/test/parse-component-facts.test.ts | 28 +++++++ packages/core/src/component.ts | 2 + packages/core/test/architecture-rules.test.ts | 1 + packages/core/test/bundle-rules.test.ts | 3 +- packages/core/test/correctness-rules.test.ts | 1 + packages/core/test/security-rules.test.ts | 1 + 8 files changed, 117 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/providers/source/components.ts b/packages/cli/src/providers/source/components.ts index e39d040fc..23ff226ac 100644 --- a/packages/cli/src/providers/source/components.ts +++ b/packages/cli/src/providers/source/components.ts @@ -23,7 +23,8 @@ export async function collectComponentFacts(rt: Runtime, cwd: string): Promise): void { } } +/** The base identifier name of a (possibly nested) member expression or identifier, else undefined. */ +function rootObjectName(node: Node): string | undefined { + let cur = node; + while (cur?.type === 'MemberExpression') cur = cur.object; + return cur?.type === 'Identifier' ? cur.name : undefined; +} + +/** + * Add state names that are WRITTEN or ESCAPED (CORRECT004 rules 1–4): reassignment, + * update, member/element assignment, method call on the state, or the state passed + * as a call argument. Run over the instance program AND the template fragment + * (inline handlers mutate state in the template). + */ +function collectStateWrites(root: Node, stateNames: Set, acc: Set): void { + walkEstree(root, (n: Node) => { + if (n?.type === 'AssignmentExpression') { + if (n.left?.type === 'Identifier' && stateNames.has(n.left.name)) acc.add(n.left.name); + else if (n.left?.type === 'MemberExpression') { + const r = rootObjectName(n.left); + if (r && stateNames.has(r)) acc.add(r); + } + } else if (n?.type === 'UpdateExpression' && n.argument?.type === 'Identifier' && stateNames.has(n.argument.name)) { + acc.add(n.argument.name); + } else if (n?.type === 'CallExpression') { + if (n.callee?.type === 'MemberExpression') { + const r = rootObjectName(n.callee); + if (r && stateNames.has(r)) acc.add(r); // x.push(), x.foo() + } + for (const a of n.arguments ?? []) { + if (a?.type === 'Identifier' && stateNames.has(a.name)) acc.add(a.name); // f(x) + } + } + }); +} + +/** + * Add state names ESCAPED via the template (CORRECT004 rules 5–6): a `bind:` on any + * element, or passed as a `Component` prop. Slot children / DOM-attribute reads do + * not escape. `CHILD_NODE_KEYS` omits `attributes`, so inspect them explicitly. + */ +function collectTemplateEscapes(node: Node, stateNames: Set, acc: Set): void { + if (Array.isArray(node)) { + for (const c of node) collectTemplateEscapes(c, stateNames, acc); + return; + } + if (!node || typeof node !== 'object' || typeof node.type !== 'string') return; + if (Array.isArray(node.attributes)) { + for (const attr of node.attributes) { + if (attr?.type === 'BindDirective') { + const r = rootObjectName(attr.expression); + if (r && stateNames.has(r)) acc.add(r); + } else if (node.type === 'Component') { + walkEstree(attr, (m: Node) => { + if (m?.type === 'Identifier' && stateNames.has(m.name)) acc.add(m.name); + }); + } + } + } + for (const key of CHILD_NODE_KEYS) { + if (key in node) collectTemplateEscapes(node[key], stateNames, acc); + } +} + const RUNE_NAMES = new Set(['$state', '$derived', '$effect', '$props', '$bindable', '$inspect', '$host']); /** @@ -609,6 +672,7 @@ export function parseComponentFacts( propCount: number; imports: string[]; namespaceImports: { source: string; line: number }[]; + constableStates: { name: string; line: number }[]; } { const ast = parse(source, { modern: true, filename }) as Node; const eachBlocks: EachBlockFact[] = []; @@ -627,6 +691,7 @@ export function parseComponentFacts( } const effects: EffectFact[] = []; + const constableStates: { name: string; line: number }[] = []; let propCount = 0; const program = ast.instance?.content; if (program) { @@ -635,9 +700,13 @@ export function parseComponentFacts( propCount = countProps(program); const stateNames = new Set(); const reactiveNames = new Set(); + const stateDecls: { name: string; line: number }[] = []; walkEstree(program, (n) => { if (n.type !== 'VariableDeclarator' || !n.init) return; - if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') stateNames.add(n.id.name); + if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') { + stateNames.add(n.id.name); + stateDecls.push({ name: n.id.name, line: lineOf(source, n.start) }); + } if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init)) addBoundNames(n.id, reactiveNames); }); @@ -651,6 +720,15 @@ export function parseComponentFacts( mountOnly: isFn ? !bodyIsEmpty(fn) && !bodyReadsReactive(fn, reactiveNames) : false }); }); + const writtenOrEscaped = new Set(); + collectStateWrites(program, stateNames, writtenOrEscaped); + if (ast.fragment) { + collectStateWrites(ast.fragment, stateNames, writtenOrEscaped); + collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped); + } + for (const d of stateDecls) { + if (!writtenOrEscaped.has(d.name)) constableStates.push(d); + } } - return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports }; + return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports, constableStates }; } diff --git a/packages/cli/test/parse-component-facts.test.ts b/packages/cli/test/parse-component-facts.test.ts index 57b629f54..dbf401515 100644 --- a/packages/cli/test/parse-component-facts.test.ts +++ b/packages/cli/test/parse-component-facts.test.ts @@ -224,3 +224,31 @@ describe('parseComponentFacts — mount-only $effect (CORRECT003)', () => { expect(facts('$effect.pre(() => { el.focus(); });')[0]!.mountOnly).toBe(true); }); }); + +describe('parseComponentFacts — constable $state (CORRECT004)', () => { + const names = (src: string) => parseComponentFacts(src, 'C.svelte').constableStates.map((s) => s.name); + + it('flags a $state that is only read', () => { + expect(names('

{title}

')).toEqual(['title']); + expect(names('

{cfg.a}

')).toEqual(['cfg']); + }); + it('does not flag a $state written in the script', () => { + expect(names('')).toEqual([]); + expect(names('')).toEqual([]); + expect(names('')).toEqual([]); + expect(names('')).toEqual([]); + }); + it('does not flag a $state mutated in an inline handler', () => { + expect(names('')).toEqual([]); + }); + it('does not flag a bound $state', () => { + expect(names('')).toEqual([]); + }); + it('does not flag a $state passed as a component prop', () => { + expect(names('')).toEqual([]); + }); + it('still flags a $state only read in a slot child or DOM attribute', () => { + expect(names('{label}')).toEqual(['label']); + expect(names('')).toEqual(['ph']); + }); +}); diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 2b32dcba1..905eddd84 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -46,4 +46,6 @@ export interface ComponentFacts { imports: string[]; /** Value `import * as X from ''` namespace imports (type-only excluded) — Bundle PERF010. */ namespaceImports: { source: string; line: number }[]; + /** `$state` declarations never written or escaped anywhere in the component — candidates for const (CORRECT004). */ + constableStates: { name: string; line: number }[]; } diff --git a/packages/core/test/architecture-rules.test.ts b/packages/core/test/architecture-rules.test.ts index f801e3d90..0d0d69dea 100644 --- a/packages/core/test/architecture-rules.test.ts +++ b/packages/core/test/architecture-rules.test.ts @@ -19,6 +19,7 @@ const comp = (over: Partial): ComponentFacts => ({ propCount: 0, imports: [], namespaceImports: [], + constableStates: [], ...over }); diff --git a/packages/core/test/bundle-rules.test.ts b/packages/core/test/bundle-rules.test.ts index 0b1fc6e40..8a023bd33 100644 --- a/packages/core/test/bundle-rules.test.ts +++ b/packages/core/test/bundle-rules.test.ts @@ -18,7 +18,8 @@ const comp = (imports: string[]): ComponentFacts => ({ loc: 10, propCount: 0, imports, - namespaceImports: [] + namespaceImports: [], + constableStates: [] }); describe('PERF009 heavy dependency import', () => { diff --git a/packages/core/test/correctness-rules.test.ts b/packages/core/test/correctness-rules.test.ts index fe3148fe7..db603b71b 100644 --- a/packages/core/test/correctness-rules.test.ts +++ b/packages/core/test/correctness-rules.test.ts @@ -19,6 +19,7 @@ const comp = (over: Partial): ComponentFacts => ({ propCount: 0, imports: [], namespaceImports: [], + constableStates: [], ...over }); diff --git a/packages/core/test/security-rules.test.ts b/packages/core/test/security-rules.test.ts index bf65fdd2f..2d83e2d3a 100644 --- a/packages/core/test/security-rules.test.ts +++ b/packages/core/test/security-rules.test.ts @@ -19,6 +19,7 @@ const comp = (over: Partial): ComponentFacts => ({ propCount: 0, imports: [], namespaceImports: [], + constableStates: [], ...over }); From 5c148e7069f42942677e5b16cca72c21b0207a00 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Thu, 2 Jul 2026 17:24:26 +0900 Subject: [PATCH 5/9] feat(core): add CORRECT004 unmutated-$state rule --- packages/core/src/index.ts | 1 + .../correctness/correct004-unmutated-state.ts | 19 ++++++++++++ packages/core/src/rules/index.ts | 3 ++ packages/core/test/correctness-rules.test.ts | 29 ++++++++++++++++++- 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/rules/correctness/correct004-unmutated-state.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8466098b4..b88fe1a22 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -71,6 +71,7 @@ export { correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, + correct004UnmutatedState, sec001Html, sec002JavascriptUrl, arch001ComponentSize, diff --git a/packages/core/src/rules/correctness/correct004-unmutated-state.ts b/packages/core/src/rules/correctness/correct004-unmutated-state.ts new file mode 100644 index 000000000..f5eca8ca0 --- /dev/null +++ b/packages/core/src/rules/correctness/correct004-unmutated-state.ts @@ -0,0 +1,19 @@ +import { componentRule } from '../component-rule.js'; + +export const correct004UnmutatedState = componentRule({ + id: 'CORRECT004', + title: 'Unmutated $state', + category: 'correctness', + severity: 'info', + label: '$state usage', + recommendation: + 'If a value never changes, use const; if you only ever reassign it wholesale (never mutate its properties), use $state.raw to skip deep proxying.', + rationale: + 'A $state that is never mutated pays for reactivity (deep proxying, tracking) it never uses; const (or $state.raw) is clearer and cheaper.', + applies: (c) => c.constableStates.length > 0, + bad: (c) => + c.constableStates.map((s) => ({ + line: s.line, + message: `$state "${s.name}" is never mutated — use const (or $state.raw if you only reassign it)` + })) +}); diff --git a/packages/core/src/rules/index.ts b/packages/core/src/rules/index.ts index bd76e249a..051abc369 100644 --- a/packages/core/src/rules/index.ts +++ b/packages/core/src/rules/index.ts @@ -38,6 +38,7 @@ import { seo027Heading } from './seo/seo027-heading.js'; import { seo028TitleUnique, seo029DescriptionUnique } from './seo/seo028-029-uniqueness.js'; import { seo030HeadingOrder } from './seo/seo030-heading-order.js'; import { correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount } from './correctness/correct001-002.js'; +import { correct004UnmutatedState } from './correctness/correct004-unmutated-state.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'; @@ -85,6 +86,7 @@ export const allRules: Rule[] = [ correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, + correct004UnmutatedState, sec001Html, sec002JavascriptUrl, arch001ComponentSize, @@ -135,6 +137,7 @@ export { correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount, + correct004UnmutatedState, sec001Html, sec002JavascriptUrl, arch001ComponentSize, diff --git a/packages/core/test/correctness-rules.test.ts b/packages/core/test/correctness-rules.test.ts index db603b71b..66985c255 100644 --- a/packages/core/test/correctness-rules.test.ts +++ b/packages/core/test/correctness-rules.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { correct001EachKey, correct002EffectDerived, correct003EffectAsOnMount } from '../src/index.js'; +import { + correct001EachKey, + correct002EffectDerived, + correct003EffectAsOnMount, + correct004UnmutatedState +} 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'; @@ -85,3 +90,25 @@ describe('CORRECT003 effect used as onMount', () => { expect(rs).toHaveLength(0); }); }); + +describe('CORRECT004 unmutated $state', () => { + it('flags a constable $state (one finding per state, with line)', async () => { + const rs = await correct004UnmutatedState.check( + ctx([comp({ constableStates: [{ name: 'title', line: 2 }] })]) + ); + expect(fails(rs)).toHaveLength(1); + expect(rs[0]!.category).toBe('correctness'); + expect(rs[0]!.line).toBe(2); + expect(rs[0]!.message).toContain('title'); + }); + it('reports one finding per distinct constable state', async () => { + const rs = await correct004UnmutatedState.check( + ctx([comp({ constableStates: [{ name: 'a', line: 2 }, { name: 'b', line: 3 }] })]) + ); + expect(fails(rs)).toHaveLength(2); + }); + it('is no-signal when there are no constable states', async () => { + const rs = await correct004UnmutatedState.check(ctx([comp({ constableStates: [] })])); + expect(rs).toHaveLength(0); + }); +}); From bc6fa86663b049acf7db4f1f38db20cf44257b31 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Thu, 2 Jul 2026 17:26:59 +0900 Subject: [PATCH 6/9] docs: CORRECT004 reference pages (en+ja) + changeset --- .changeset/correct004-unmutated-state.md | 12 +++++++++ docs/src/content/docs/ja/rules/correct004.md | 27 ++++++++++++++++++++ docs/src/content/docs/rules/correct004.md | 27 ++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .changeset/correct004-unmutated-state.md create mode 100644 docs/src/content/docs/ja/rules/correct004.md create mode 100644 docs/src/content/docs/rules/correct004.md diff --git a/.changeset/correct004-unmutated-state.md b/.changeset/correct004-unmutated-state.md new file mode 100644 index 000000000..58a419f35 --- /dev/null +++ b/.changeset/correct004-unmutated-state.md @@ -0,0 +1,12 @@ +--- +'@svelte-vitals/core': minor +'svelte-vitals': minor +'@svelte-vitals/mcp': minor +--- + +Add **CORRECT004 (unmutated $state)** — a Correctness/reactivity rule from #69. +Flags a `let x = $state(...)` that is never written or escaped anywhere in the +component (no reassignment, member/method mutation, bind, call-arg, or +component-prop pass), so its reactivity is unused — use `const` (or `$state.raw` +if only reassigned wholesale). Reported under `correctness` (info). `ComponentFacts` +gains `constableStates`. diff --git a/docs/src/content/docs/ja/rules/correct004.md b/docs/src/content/docs/ja/rules/correct004.md new file mode 100644 index 000000000..4e29d453b --- /dev/null +++ b/docs/src/content/docs/ja/rules/correct004.md @@ -0,0 +1,27 @@ +--- +title: CORRECT004 · 変更されない $state +description: 変更されない $state には const(または $state.raw)を使います。 +--- + +**重大度:** info · **カテゴリ:** correctness + +## チェック内容 + +コンポーネント内のどこでも書き込み・エスケープされない `let x = $state(...)` を検出します — 再代入なし、変更なし(`x.a = …`、`x.push()`)、バインドなし(`bind:value={x}`)、関数やコンポーネントへの受け渡しなしのものです。コンポーネントのスクリプトとテンプレートを静的(CLI)解析します。 + +## なぜ重要か + +変更されない `$state` は、使わないリアクティビティ(deep proxy と依存追跡)のコストを払っています。`const` の方が明確で軽量です。値をまるごと差し替えるだけ(プロパティは変更しない)なら `$state.raw` が適します。 + +## 修正方法 + +```svelte + +``` diff --git a/docs/src/content/docs/rules/correct004.md b/docs/src/content/docs/rules/correct004.md new file mode 100644 index 000000000..43b7c3ef1 --- /dev/null +++ b/docs/src/content/docs/rules/correct004.md @@ -0,0 +1,27 @@ +--- +title: CORRECT004 · Unmutated $state +description: Use const (or $state.raw) for a $state that is never mutated. +--- + +**Severity:** info · **Category:** correctness + +## What it checks + +Flags a `let x = $state(...)` whose value is never written or escaped anywhere in the component — not reassigned, not mutated (`x.a = …`, `x.push()`), not bound (`bind:value={x}`), not passed to a function or component. Checked by static (CLI) analysis of the component script and template. + +## Why it matters + +A `$state` that is never mutated pays for reactivity — deep proxying and dependency tracking — that it never uses. `const` is clearer and cheaper; `$state.raw` fits when you only ever reassign the value wholesale (never mutate its properties). + +## How to fix + +```svelte + +``` From 231c3ae968f3785b2809ee325b1fa0ac5cc4572c Mon Sep 17 00:00:00 2001 From: oekazuma Date: Thu, 2 Jul 2026 17:28:57 +0900 Subject: [PATCH 7/9] chore: format CORRECT004 changes Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-02-correct004-unmutated-state.md | 112 +++++++++++------- ...07-02-correct004-unmutated-state-design.md | 8 +- packages/core/test/correctness-rules.test.ts | 13 +- 3 files changed, 83 insertions(+), 50 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-correct004-unmutated-state.md b/docs/superpowers/plans/2026-07-02-correct004-unmutated-state.md index 9f0d186f2..10ead0fd6 100644 --- a/docs/superpowers/plans/2026-07-02-correct004-unmutated-state.md +++ b/docs/superpowers/plans/2026-07-02-correct004-unmutated-state.md @@ -41,12 +41,14 @@ ### Task 1: Capture `constableStates` **Files:** + - Modify: `packages/core/src/component.ts` (add field after `namespaceImports`) - Modify: `packages/cli/src/providers/source/parse.ts` (helpers near `addBoundNames`; return type; instance block; return object) - Modify: `packages/cli/test/parse-component-facts.test.ts` (add capture tests) - Modify: `packages/core/test/correctness-rules.test.ts`, `security-rules.test.ts`, `architecture-rules.test.ts`, `bundle-rules.test.ts` (add `constableStates: []` to each ComponentFacts helper) **Interfaces:** + - Produces: `ComponentFacts.constableStates: { name: string; line: number }[]`; `parseComponentFacts` sets it. - Consumes: existing `walkEstree`, `lineOf`, `isStateDeclaration`, `CHILD_NODE_KEYS`. @@ -55,8 +57,12 @@ In `packages/core/src/component.ts`, after the `namespaceImports: …` field, inside `ComponentFacts`, add: ```ts - /** `$state` declarations never written or escaped anywhere in the component — candidates for const (CORRECT004). */ - constableStates: { name: string; line: number }[]; +/** `$state` declarations never written or escaped anywhere in the component — candidates for const (CORRECT004). */ +constableStates: { + name: string; + line: number; +} +[]; ``` - [ ] **Step 2: Write the failing capture tests** @@ -172,48 +178,52 @@ function collectTemplateEscapes(node: Node, stateNames: Set, acc: Set { - if (n.type !== 'VariableDeclarator' || !n.init) return; - if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') stateNames.add(n.id.name); - if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init)) - addBoundNames(n.id, reactiveNames); - }); +walkEstree(program, (n) => { + if (n.type !== 'VariableDeclarator' || !n.init) return; + if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') stateNames.add(n.id.name); + if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init)) + addBoundNames(n.id, reactiveNames); +}); ``` to: ```ts - const stateDecls: { name: string; line: number }[] = []; - walkEstree(program, (n) => { - if (n.type !== 'VariableDeclarator' || !n.init) return; - if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') { - stateNames.add(n.id.name); - stateDecls.push({ name: n.id.name, line: lineOf(source, n.start) }); - } - if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init)) - addBoundNames(n.id, reactiveNames); - }); +const stateDecls: { name: string; line: number }[] = []; +walkEstree(program, (n) => { + if (n.type !== 'VariableDeclarator' || !n.init) return; + if (isStateDeclaration(n.init) && n.id?.type === 'Identifier') { + stateNames.add(n.id.name); + stateDecls.push({ name: n.id.name, line: lineOf(source, n.start) }); + } + if (isStateDeclaration(n.init) || isDerivedDeclaration(n.init) || isPropsCall(n.init)) + addBoundNames(n.id, reactiveNames); +}); ``` - [ ] **Step 6: Compute `constableStates`** @@ -221,15 +231,15 @@ to: Still in the instance block, after the effects-collecting `walkEstree(...)` call (the one that pushes to `effects`), add: ```ts - const writtenOrEscaped = new Set(); - collectStateWrites(program, stateNames, writtenOrEscaped); - if (ast.fragment) { - collectStateWrites(ast.fragment, stateNames, writtenOrEscaped); - collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped); - } - for (const d of stateDecls) { - if (!writtenOrEscaped.has(d.name)) constableStates.push(d); - } +const writtenOrEscaped = new Set(); +collectStateWrites(program, stateNames, writtenOrEscaped); +if (ast.fragment) { + collectStateWrites(ast.fragment, stateNames, writtenOrEscaped); + collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped); +} +for (const d of stateDecls) { + if (!writtenOrEscaped.has(d.name)) constableStates.push(d); +} ``` - [ ] **Step 7: Return `constableStates`** @@ -237,13 +247,13 @@ Still in the instance block, after the effects-collecting `walkEstree(...)` call Change the return statement: ```ts - return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports }; +return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports }; ``` to: ```ts - return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports, constableStates }; +return { eachBlocks, effects, htmlTags, javascriptUrls, loc, propCount, imports, namespaceImports, constableStates }; ``` - [ ] **Step 8: Update the provider catch fallback and core test helpers** @@ -277,12 +287,14 @@ git commit -m "feat(cli): capture constableStates for CORRECT004" ### Task 2: CORRECT004 rule + registration **Files:** + - Create: `packages/core/src/rules/correctness/correct004-unmutated-state.ts` - Modify: `packages/core/src/rules/index.ts` (import ~line 40; `allRules`; re-export) - Modify: `packages/core/src/index.ts` (re-export after `correct003EffectAsOnMount`) - Test: `packages/core/test/correctness-rules.test.ts` (add CORRECT004 describe block) **Interfaces:** + - Consumes: `componentRule`; `ComponentFacts.constableStates` (Task 1). - Produces: `export const correct004UnmutatedState: Rule`. @@ -293,9 +305,7 @@ In `packages/core/test/correctness-rules.test.ts`, add `correct004UnmutatedState ```ts describe('CORRECT004 unmutated $state', () => { it('flags a constable $state (one finding per state, with line)', async () => { - const rs = await correct004UnmutatedState.check( - ctx([comp({ constableStates: [{ name: 'title', line: 2 }] })]) - ); + const rs = await correct004UnmutatedState.check(ctx([comp({ constableStates: [{ name: 'title', line: 2 }] })])); expect(fails(rs)).toHaveLength(1); expect(rs[0]!.category).toBe('correctness'); expect(rs[0]!.line).toBe(2); @@ -303,7 +313,14 @@ describe('CORRECT004 unmutated $state', () => { }); it('reports one finding per distinct constable state', async () => { const rs = await correct004UnmutatedState.check( - ctx([comp({ constableStates: [{ name: 'a', line: 2 }, { name: 'b', line: 3 }] })]) + ctx([ + comp({ + constableStates: [ + { name: 'a', line: 2 }, + { name: 'b', line: 3 } + ] + }) + ]) ); expect(fails(rs)).toHaveLength(2); }); @@ -384,6 +401,7 @@ git commit -m "feat(core): add CORRECT004 unmutated-\$state rule" ### Task 3: Docs + changeset **Files:** + - Create: `docs/src/content/docs/rules/correct004.md`, `docs/src/content/docs/ja/rules/correct004.md` - Create: `.changeset/correct004-unmutated-state.md` @@ -391,7 +409,7 @@ git commit -m "feat(core): add CORRECT004 unmutated-\$state rule" Create `docs/src/content/docs/rules/correct004.md`: -```md +````md --- title: CORRECT004 · Unmutated $state description: Use const (or $state.raw) for a $state that is never mutated. @@ -419,7 +437,9 @@ A `$state` that is never mutated pays for reactivity — deep proxying and depen data = nextValue; ``` -``` +```` + +```` - [ ] **Step 2: Write the Japanese doc** @@ -452,8 +472,9 @@ description: 変更されない $state には const(または $state.raw)を let data = $state.raw(initial); data = nextValue; -``` -``` +```` + +```` - [ ] **Step 3: Write the changeset** @@ -472,7 +493,7 @@ component (no reassignment, member/method mutation, bind, call-arg, or component-prop pass), so its reactivity is unused — use `const` (or `$state.raw` if only reassigned wholesale). Reported under `correctness` (info). `ComponentFacts` gains `constableStates`. -``` +```` - [ ] **Step 4: Verify docs build** @@ -495,9 +516,11 @@ git commit -m "docs: CORRECT004 reference pages (en+ja) + changeset" - [ ] **Step 1: Build core, then run the whole suite / typecheck / lint / docs build** Run: + ```bash pnpm -r build && pnpm -r test && pnpm -r typecheck && pnpm lint && pnpm --filter docs build ``` + Expected: all green. Core test count rises by 3 (CORRECT004 rule tests); cli by ~6 (constable capture tests). - [ ] **Step 2: If lint reports formatting, fix and re-run** @@ -517,6 +540,7 @@ git commit -m "chore: format CORRECT004 changes" ## Self-Review **Spec coverage:** + - `ComponentFacts.constableStates` field → Task 1 Step 1. ✓ - Write detection (assign/update/member/method/call-arg) over script + template → `collectStateWrites` run on program AND fragment, Task 1 Steps 4, 6. ✓ - Template escapes (bind: + component prop; slot/DOM-attr reads excluded) → `collectTemplateEscapes`, Task 1 Step 4. ✓ diff --git a/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md b/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md index ac4a90e18..ee285cc2c 100644 --- a/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md +++ b/docs/superpowers/specs/2026-07-02-correct004-unmutated-state-design.md @@ -38,7 +38,7 @@ warn about a never-mutated `$state`. A declared `$state` name is **suppressed** (not flagged) when, anywhere in the component, it is: -**Script AND template expressions (ESTree walk over the instance program *and* +**Script AND template expressions (ESTree walk over the instance program _and_ the template fragment):** — writes 1–4 are detected in both places, because a `$state` is commonly mutated in an inline event handler (`