Skip to content

ESLint plugin

eslint-plugin-kerfjs is a companion ESLint plugin that catches kerf hard-rule violations at edit time, before they reach tsc or the runtime dev-warns. It sits alongside two earlier defense layers shipped by kerf:

LayerCatchesWhen
tsc --noEmit with strict typingsMost type-shaped bugs (e.g. partial-set against multi-key store state)Build time
Opt-in dev-warns (KERF_DEV_WARN_*)Rebuilt listeners, untracked signals, narrow setRuntime
eslint-plugin-kerfjsInline JSX handlers, missing data-key, nested mount(), global JSX augmentationEdit time

The rules are AST-only — no @typescript-eslint/parser service dependency is required by the plugin (consumers configure their own parser). This keeps consumer setup trivial and the plugin’s release cadence independent of TypeScript-ESLint major upgrades.

Terminal window
npm install --save-dev eslint-plugin-kerfjs
eslint.config.js
import kerfjs from 'eslint-plugin-kerfjs';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
kerfjs.configs.recommended,
];
{
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaFeatures": { "jsx": true } },
"extends": ["plugin:kerfjs/legacy-recommended"]
}

Most rules ship as error in the recommended preset (AST-shaped antipatterns are bugs). prefer-attr-selector, no-raw-with-dynamic-arg, and ai-assistant-configs ship as warn — they’re nudges, audit trails, or project-hygiene checks, not correctness bugs.

Disallow inline onClick-style JSX event handler attributes on intrinsic (lowercase-tag) elements. Use a data-action attribute and delegate() from the mount root instead.

// ❌
<button onClick={save}>Save</button>
// ✅
<button data-action="save">Save</button>
delegate(rootEl, 'click', '[data-action="save"]', save);

Require data-key (or id) on the root element returned from an each() row render. Without a key, the keyed reconciler matches by position and loses identity, focus, and cursor position on insert / delete.

// ❌
each(items, (item) => <li>{item.name}</li>)
// ✅
each(items, (item) => <li data-key={item.id}>{item.name}</li>)

Disallow mount() calls inside another mount()’s render callback. Composition is via plain functions that return JSX, not nested mount trees.

// ❌
mount(root, () => {
mount(otherRoot, () => <div />);
return <div />;
});
// ✅
mount(headerRoot, () => <Header />);
mount(bodyRoot, () => <Body />);

Disallow declaration-merging JSX.IntrinsicElements into the global namespace. kerf’s JSX runtime reads custom-element typings from its own module’s JSX namespace, so global augmentations don’t flow through.

// ❌
declare global {
namespace JSX {
interface IntrinsicElements { 'my-tag': { foo?: string } }
}
}
// ✅
declare module 'kerfjs/jsx-runtime' {
namespace JSX {
interface IntrinsicElements { 'my-tag': KerfCustomElement & { foo?: string } }
}
}

When delegate() / delegateCapture() is called with a literal [name="value"] selector string, nudge toward defining attr('name', 'value') once and passing its .selector — so the JSX ({...spec.attrs}) and the delegate target stay synchronized through a single typed source. Rename-safety; not a correctness rule. Severity: warn.

// ❌ — JSX attribute and selector string are independent literals
<button data-action="toggle">Toggle</button>
delegate(root, 'click', '[data-action="toggle"]', handler);
// ✅ — one typed constant drives both
const TOGGLE = attr('data-action', 'toggle');
<button {...TOGGLE.attrs}>Toggle</button>
delegate(root, 'click', TOGGLE.selector, handler);

Rules that need flow analysis (signal reads outside render), call-graph analysis (addEventListener inside the mount tree), or type information (partial-set against multi-key state) are already covered by the opt-in dev-warns and strict TS. Duplicating them in lint would mean either high false-positive rates without type info, or a parserServices dependency that complicates consumer setup.

eslint-plugin-kerfjs lives in the kerf monorepo under eslint-plugin/. Each rule has a longer docs page with edge cases and what-it-doesn’t-catch notes.