Using cascivo with a strict host ESLint config

Short version:

pnpm add -D @cascivo/eslint-config
// 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:

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

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

{ "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:

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, 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:

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.

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.

<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 below). AI-RULES.md and HEADLESS.md teach this form.

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

{ 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):

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/**):

// 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

{
  "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:

See also

As Markdown: /docs/using-with-strict-eslint.md

← All guides