<!--
  Generated from docs/ — do not edit here; run `pnpm regen`.
  Canonical: https://cascivo.com/docs/using-with-strict-eslint.md
  registry v1.6.0 · generated 2026-10-02
-->

# Using cascivo with a strict host ESLint config

**Short version:**

```sh
pnpm add -D @cascivo/eslint-config
```

```js
// eslint.config.js
import cascivo from '@cascivo/eslint-config'

export default [
  // …your existing config…
  ...cascivo, // spread LAST — flat config is last-wins
]
```

That covers both problems on this page. Read on for what it does and why.

## If you lint with oxlint, not ESLint

**Read this first if you started with `pnpm create vite --template react-ts`.** That
scaffold ships **oxlint** and a `.oxlintrc.json` in 2026 — there is no `eslint.config.js`
to spread anything into, so the block above does not apply to you. oxlint reimplements
`react-hooks/immutability` as `react/immutability`, and it reports the same thing about
cascivo's state idiom:

```
src/routes/shell.tsx:93:13: warning react(immutability): This value cannot be modified
```

The fix is one rule. Extend the fragment this package ships:

```jsonc
// .oxlintrc.json
{
  "extends": ["./node_modules/@cascivo/eslint-config/src/oxlintrc.json"],
  "categories": { "correctness": "error" },
}
```

…or copy the rule, which is all the fragment contains:

```jsonc
{ "rules": { "react/immutability": "off" } }
```

⚠ oxlint **rejects a config naming a rule its build does not implement**
(`Rule 'immutability' not found in plugin 'react'`), so add this only on an oxlint recent
enough to ship the rule. On an older one the rule does not fire, so you need neither.

Everything below about _what turning the rule off costs_ applies identically — it is the
same rule, ported.

Until the 2026-08-31 report (§13) this page mentioned oxlint three times, all of them
describing cascivo's **own internal** linter, and shipped no oxlint story for the linter its
most common scaffold now installs.

### If your `outputDir` is not `src/components/ui`

`...cascivo` scopes its vendored-source rules to `src/components/ui/**`. If `cascivo add`
writes somewhere else, pass your `outputDir` — **with the default glob, every rule the
fragment scopes off silently stays on**, and you get the full error list back:

```js
import { cascivoSignals, cascivoVendoredSource } from '@cascivo/eslint-config'

export default [
  // …your existing config…
  cascivoSignals,
  cascivoVendoredSource('app/ui/**'), // ← your outputDir, as a glob
]
```

### The exact config cascivo tests against

This is not an illustration. The block below is
[`scripts/checks/host-lint/eslint/eslint.config.js`](https://github.com/cascivo/cascivo/blob/main/scripts/checks/host-lint/eslint/eslint.config.js),
copied here by a test that fails if the two drift — and CI runs real ESLint with it over
every file `cascivo add` copies, asserting zero errors. It is a TanStack Start scaffold's
config with `@cascivo/eslint-config` added:

<!-- host-lint:eslint-config -->

```js
import { tanstackConfig } from '@tanstack/eslint-config'
import reactHooks from 'eslint-plugin-react-hooks'
import {
  cascivoPropVocabulary,
  cascivoSignals,
  cascivoVendoredSource,
} from '@cascivo/eslint-config'

export default [
  ...tanstackConfig,
  // NOTE the `.flat` — `reactHooks.configs['recommended-latest']` is the legacy
  // eslintrc shape and does nothing in a flat config.
  reactHooks.configs.flat['recommended-latest'],
  // Spread LAST — flat config is last-wins.
  cascivoSignals,
  // Reports the prop names adopters guess wrong, with the prop that exists. `warn`, so it
  // never fails a build; the fixture prints warnings and gates only on errors.
  cascivoPropVocabulary,
  // Pass YOUR `outputDir` from cascivo.config.ts. The no-argument default is
  // 'src/components/ui/**'; if your outputDir differs and you rely on the default,
  // every rule this fragment scopes off silently stays on.
  cascivoVendoredSource('packages/components/src/**'),
]
```

⚠ **`reactHooks.configs.flat['recommended-latest']`, not
`reactHooks.configs['recommended-latest']`.** The plugin exports both; the second is the
legacy eslintrc shape and applies nothing in a flat config, with no error to tell you.

### What `cascivoPropVocabulary` adds

It enables one rule, `cascivo/prop-vocabulary`, at **`warn`**. The rule answers a wrong prop
guess with the prop that exists:

```
`Text` has no `tone` prop — it is `muted`. `tone` is the catalog's SEVERITY vocabulary
(Status, Badge, Timeline, SideNav). Text emphasis is the boolean `muted`.
```

TypeScript already rejects `<Text tone="subtle">`; its message ("Property 'tone' does not
exist on type 'TextProps'") names the mistake and does not say what to write instead, so you
go looking for the docs. The rule also autofixes `gap="4"` → `gap={4}` and flags
`const { theme } = useTheme()` (it returns a tuple) and `<Flex justify=…>` with no
`direction` (`Flex` is vertical by default).

It is `warn` on purpose — a lint error over a naming opinion is a reason to delete the whole
config, which would take `react-hooks/immutability` with it. Raise it yourself if you want it
enforced. Full list: [`@cascivo/eslint-plugin`](https://github.com/cascivo/cascivo/blob/main/packages/eslint-plugin/README.md).

### What `cascivoTokenValues` adds

`cascivo/token-values`, also at `warn`. It reports a `--cascivo-*` custom property that does
not exist — the one styling mistake nothing else in your stack will ever mention, because CSS
drops an unknown custom property silently and React's `CSSProperties` has no index signature
for `--*` keys to type-check them against.

```tsx
<div style={{ '--cascivo-color-acent': 'red' }} />
//            ^ warns: did you mean `--cascivo-color-accent`?
```

Only the `--cascivo-` namespace is checked; your own custom properties are ignored. For the
CSS half — and for `data-cascivo-*` selectors that match nothing — run `cascivo audit --ai`,
which reports the same class at error level.

### Formatting: exclude vendored source from your formatter

Owning the code means your formatter will reformat it, and `cascivo update` will then
report drift on files you never edited. Add your `outputDir` to `.prettierignore` (or
`.oxfmtignore`):

```
src/components/ui/
```

`cascivo init` writes this for you when it finds a formatter config, and `cascivo doctor`
reports it as a finding if it is missing.

---

## 1. `react-hooks/immutability` errors on every signal write

**This affects every cascivo app, on both install paths.** It is not a
strict-config problem — `eslint-plugin-react-hooks@7` with `recommended-latest`
is what a stock 2026 React app gets.

The error looks like this, and you will get one for every piece of state you
wrote:

```
error  Error: This value cannot be modified
Modifying a value returned from a hook is not allowed.
  onValueChange={(v) => (env.value = v)}
                         ^^^ `env` cannot be modified
```

The rule fires on assignments to a signal a hook returned — `open.value = !open.value` —
which older cascivo docs taught as the way to write state.

**Preferred fix: write through a setter.** Hold state with `useSignalState` and call its
setter (`setOpen(!open.value)`); that is not a mutation, so the rule passes, and the same code
compiles under the React Compiler (see [React Compiler](#react-compiler) below).
[AI-RULES.md](/docs/ai-rules.md) and [HEADLESS.md](/docs/headless.md) teach this form.

**If you have many existing assignments:** install `@cascivo/eslint-config` as above, or set
the rule yourself:

```js
{ rules: { 'react-hooks/immutability': 'off' } }
```

**Why it can't be narrowed.** The rule cannot distinguish a deliberate signal
write from an accidental mutation of `useState` output, and it offers no
hook-name allowlist. Turning it off is the only mechanism available.

**What that costs.** You lose the rule's protection against genuinely mutating
React state elsewhere in your files. That is a real loss. If you would rather
keep it, skip the `cascivoSignals` fragment and put a
`// eslint-disable-next-line react-hooks/immutability` above each signal
assignment instead.

**Note the scope.** The directory-scoped recipe in §2 does **not** help here:
signal writes live in your own page and component code, and on the prebuilt path
(`@cascivo/react`) the `src/components/ui/**` directory does not exist at all.

### React Compiler

Measured, not assumed — `apps/examples/react-vite` runs its tests a second time with its own
source compiled by `babel-plugin-react-compiler` 1.x (`pnpm compiler:check`, in CI):

- **`signal.value = next` does not compile.** The compiler rejects a component that assigns to
  a value returned from a hook ("This value cannot be modified") and, with
  `panicThreshold: 'all_errors'`, fails the build. With the default threshold it skips the
  component instead, so the component runs uncompiled — correct, but not optimized.
- **Writing through a setter compiles, and updates correctly.** Hold state with
  `useSignalState` and call its setter; reading `signal.value` in render is fine:

  ```tsx
  const [open, setOpen] = useSignalState(false)
  // render: open.value · handlers: setOpen(!open.value)
  ```

  A controlled prop bridged with `useControllableSignal` returns the same `[signal, setter]`
  pair and works the same way. Module-level `signal()`s can be assigned directly.

  The compiler tracks `open.value` as a dependency, so memoized output re-renders when the
  value changes — the example's interaction tests pass under it.

- **The same form satisfies `react-hooks/immutability`**, so with setters you can keep that
  rule on instead of using the override above.

cascivo's own components are unaffected either way: they ship `'use client'` and do not rely
on compiler memoization. The rule applies to the signal-writing components _you_ write.

---

## 2. Host stylistic rules flag vendored source

**Copy-paste path only.** When you `cascivo add` a component, you vendor its source into your
project (`src/components/ui/**` by default). That code is generated-style code you
own but did not write, and a strict host config — `@tanstack/eslint-config`,
`eslint-config-airbnb`, a bespoke typescript-eslint strict setup — will flag it
against **its** house style, not cascivo's. That is expected: cascivo's own lint
bar (Oxlint) is deliberately not every downstream config's bar, and chasing every
host's stylistic preferences inside vendored code is a losing game that
`cascivo update` would undo on the next re-copy anyway.

The fix is a one-time, durable ESLint override for your cascivo output directory —
`@cascivo/eslint-config`'s `cascivoVendoredSource()` fragment is exactly the block
below, or write it by hand:

### The recipe (flat config)

Add this block to `eslint.config.js` (adjust the glob to your `outputDir` from
`cascivo.config.ts` — the default is `src/components/ui/**`):

```js
// eslint.config.js
export default [
  // …your existing config…
  {
    // Vendored cascivo source — you own it, but it is generated-style code.
    // Scope host stylistic rules off it; keep correctness rules on.
    files: ['src/components/ui/**'],
    rules: {
      // Style: `T[]` vs `Array<T>`, import ordering, generic-param naming
      // (`Row` vs `TRow`), method-signature style — cascivo does not adopt these.
      '@typescript-eslint/array-type': 'off',
      '@typescript-eslint/naming-convention': 'off',
      '@typescript-eslint/method-signature-style': 'off',
      'sort-imports': 'off',
      'import/order': 'off',
      // Opinionated / misfires on legitimate cascivo patterns:
      'react/no-array-index-key': 'off', // stable-content lists key by index intentionally
      'no-shadow': 'off', // false-positives on TS declaration-merging (compound components)
      'no-control-regex': 'off', // e.g. the log viewer strips ANSI escapes (\x1b) on purpose
    },
    linterOptions: {
      // cascivo's rule-scoped `eslint-disable` directives may target rule ids
      // your config doesn't define — don't report them as "unused".
      reportUnusedDisableDirectives: 'off',
    },
  },
]
```

cascivo enforces the objective classes below in its own linter (oxlint) via
`pnpm lint:host-strict`, so the copied source stays clean against a strict host
config for every rule outside the scope-off list above.

### `.eslintrc` (legacy) equivalent

```json
{
  "overrides": [
    {
      "files": ["src/components/ui/**"],
      "rules": {
        "@typescript-eslint/array-type": "off",
        "@typescript-eslint/naming-convention": "off",
        "@typescript-eslint/method-signature-style": "off",
        "sort-imports": "off",
        "import/order": "off",
        "react/no-array-index-key": "off",
        "no-shadow": "off",
        "no-control-regex": "off"
      },
      "linterOptions": { "reportUnusedDisableDirectives": false }
    }
  ]
}
```

## Why not just fix the vendored source?

cascivo keeps the **objective** classes clean at the source — inline vs top-level
type specifiers, unnecessary type assertions, `prefer-const`, and stale
`eslint-disable` directives are treated as defects in the component library
itself; the syntactic ones are enforced in CI by `pnpm lint:host-strict` (which
runs oxlint, no ESLint dependency). What this page scopes off is the
**stylistic** layer that is one config's opinion:
generic-parameter naming (`Row` vs `TRow`), import ordering nuances, and unused-
directive reporting for rule ids your config doesn't share. Those are not worth
editing vendored files for, because:

- `cascivo update` re-copies the source, so any manual edit is lost on the next update.
- The readable single-letter-avoiding generic names (`Row`, `Column`) are part of
  the point of owning readable source; renaming them to `TRow` reduces readability
  to satisfy a naming convention cascivo intentionally does not adopt.

## See also

- [GETTING-STARTED.md](/docs/getting-started.md) — install + the files the CLI manages.
- [AI-RULES.md](/docs/ai-rules.md) — the reactivity contract that makes §1's rule fire.
- [HEADLESS.md](/docs/headless.md) — the signal primitives, and the `open.value = !open.value`
  example the rule reports.
- [TROUBLESHOOTING.md](/docs/troubleshooting.md) — keyed on the literal error text.
