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):
-
signal.value = nextdoes not compile. The compiler rejects a component that assigns to a value returned from a hook ("This value cannot be modified") and, withpanicThreshold: '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
useSignalStateand call its setter; readingsignal.valuein render is fine:const [open, setOpen] = useSignalState(false) // render: open.value · handlers: setOpen(!open.value)A controlled prop bridged with
useControllableSignalreturns the same[signal, setter]pair and works the same way. Module-levelsignal()s can be assigned directly.The compiler tracks
open.valueas 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/**):
// 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:
cascivo updatere-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 toTRowreduces readability to satisfy a naming convention cascivo intentionally does not adopt.
See also
- GETTING-STARTED.md — install + the files the CLI manages.
- AI-RULES.md — the reactivity contract that makes §1's rule fire.
- HEADLESS.md — the signal primitives, and the
open.value = !open.valueexample the rule reports. - TROUBLESHOOTING.md — keyed on the literal error text.
As Markdown: /docs/using-with-strict-eslint.md