eslint-plugin-kerfjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # eslint-plugin-kerfjs
2
+
3
+ ESLint rules that enforce kerf's hard rules. Catches AI-shaped bugs at edit time, before they reach `tsc` or the runtime dev-warns.
4
+
5
+ This plugin sits alongside two other defense layers shipped by [`kerfjs`](https://github.com/brianwestphal/kerf):
6
+
7
+ | Layer | Catches | When |
8
+ |---|---|---|
9
+ | `tsc --noEmit` with strict typings | Hard Rules 8 (partial-set), most type errors | Build time |
10
+ | Opt-in dev-warns (`KERF_DEV_WARN_*`) | Hard Rules 4 (rebuilt listeners), 7 (untracked signals), 8 (narrow set) | Runtime |
11
+ | **This plugin** | Hard Rules 2, 5, 9, 11 — AST-shaped antipatterns | **Edit time** |
12
+
13
+ The rules are AST-only — no `@typescript-eslint/parser` *service* dependency is required by the plugin (consumers configure their own parser).
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install --save-dev eslint-plugin-kerfjs
19
+ ```
20
+
21
+ ## Configure (flat config, ESLint v9+)
22
+
23
+ ```js
24
+ // eslint.config.js
25
+ import kerfjs from 'eslint-plugin-kerfjs';
26
+ import tsParser from '@typescript-eslint/parser';
27
+
28
+ export default [
29
+ {
30
+ files: ['**/*.ts', '**/*.tsx'],
31
+ languageOptions: {
32
+ parser: tsParser,
33
+ parserOptions: { ecmaFeatures: { jsx: true } },
34
+ },
35
+ },
36
+ kerfjs.configs.recommended,
37
+ ];
38
+ ```
39
+
40
+ ## Configure (legacy `.eslintrc`)
41
+
42
+ ```json
43
+ {
44
+ "parser": "@typescript-eslint/parser",
45
+ "parserOptions": { "ecmaFeatures": { "jsx": true } },
46
+ "extends": ["plugin:kerfjs/legacy-recommended"]
47
+ }
48
+ ```
49
+
50
+ ## Rules
51
+
52
+ | Rule | Hard Rule | Severity (recommended) |
53
+ |---|---|---|
54
+ | [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 9 — use `data-action` + `delegate()` | `error` |
55
+ | [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
56
+ | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 5 — one `mount()` per root | `error` |
57
+ | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 11 — augment `kerfjs/jsx-runtime`, not global | `error` |
58
+
59
+ The "Hard Rule" column refers to the numbered rules in [`docs/ai/usage-guide.md`](../docs/ai/usage-guide.md) on the main kerf repo.
60
+
61
+ ## Why these four (and not more)?
62
+
63
+ Rules that need flow analysis (signal reads outside render — Rule 7), call-graph analysis (`addEventListener` inside the mount tree — Rule 4), or type information (partial-set against multi-key state — Rule 8) are already covered by the opt-in dev-warns and strict TS. Duplicating them here would mean either high false-positive rates without type info, or a `parserServices` dependency that complicates consumer setup.
64
+
65
+ When a real bug ships that the existing defense stack misses AND a new lint rule would not false-positive on legitimate code, file an issue on the main kerf repo.
66
+
67
+ ## Develop / test
68
+
69
+ ```bash
70
+ npm install
71
+ npm test
72
+ ```
73
+
74
+ The test suite uses ESLint's `RuleTester` with `@typescript-eslint/parser`.
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,36 @@
1
+ # `kerfjs/no-inline-jsx-event-handlers`
2
+
3
+ Disallow inline `onClick`-style JSX event handler attributes on intrinsic (lowercase-tag) elements.
4
+
5
+ Maps to **kerf Hard Rule 9** — kerf's JSX runtime renders to HTML strings, so an inline `onClick={fn}` has no way to attach the handler to the resulting node. Use a `data-action` attribute and `delegate()` from the mount root instead.
6
+
7
+ ## ❌ Incorrect
8
+
9
+ ```tsx
10
+ <button onClick={save}>Save</button>
11
+ <input onChange={update} />
12
+ <form onSubmit={submit}>…</form>
13
+ ```
14
+
15
+ ## ✅ Correct
16
+
17
+ ```tsx
18
+ // In the template:
19
+ <button data-action="save">Save</button>
20
+ <input data-action="update" />
21
+ <form data-action="submit">…</form>
22
+
23
+ // Once, at module init:
24
+ delegate(rootEl, 'click', '[data-action="save"]', save);
25
+ delegate(rootEl, 'input', '[data-action="update"]', update);
26
+ delegate(rootEl, 'submit', '[data-action="submit"]', submit);
27
+ ```
28
+
29
+ ## Why this rule is AST-only
30
+
31
+ The check is a pure syntactic scan: attribute name starts with `on` followed by an uppercase letter, on a JSX element whose tag begins with a lowercase letter. No type information needed.
32
+
33
+ ## What this rule does NOT catch
34
+
35
+ - Handler-shaped props on custom components (`<MyButton onClick={fn} />`) — these are valid JSX prop names; kerf's runtime calls `MyButton({ onClick })`. Whether the component does the right thing with it is its own responsibility.
36
+ - Lowercase HTML attributes that begin with `on` (e.g. `onload` as a literal string attribute on `<body onload="…">`) — these are HTML-string attributes, not handlers, and kerf passes them through verbatim.
@@ -0,0 +1,34 @@
1
+ # `kerfjs/no-nested-mount`
2
+
3
+ Disallow `mount()` calls nested inside another `mount()`'s render callback.
4
+
5
+ Maps to **kerf Hard Rule 5** — there is one `mount()` per render root. Composition is via plain functions that return JSX, not via nesting another mount tree inside an outer one.
6
+
7
+ ## ❌ Incorrect
8
+
9
+ ```tsx
10
+ mount(root, () => {
11
+ mount(otherRoot, () => <div>nested</div>); // ← reported
12
+ return <div>outer</div>;
13
+ });
14
+
15
+ mount(root, () => mount(otherRoot, () => <div />)); // ← reported
16
+ ```
17
+
18
+ ## ✅ Correct
19
+
20
+ ```tsx
21
+ // Two sibling roots, mounted at module init:
22
+ mount(headerRoot, () => <Header />);
23
+ mount(bodyRoot, () => <Body />);
24
+
25
+ // Composition via plain functions:
26
+ const Header = () => <h1>{title.value}</h1>;
27
+ const Body = () => <main><Header /><div>{count.value}</div></main>;
28
+ mount(root, () => <Body />);
29
+ ```
30
+
31
+ ## What this rule does NOT catch
32
+
33
+ - `mount()` called from a helper invoked from within a render: the rule only walks lexical ancestors, so `mount(root, () => { helperThatCallsMount(); })` is not flagged.
34
+ - `mount()` qualified by a namespace (`MyLib.mount(…)`) — only the bare `mount` identifier is checked.
@@ -0,0 +1,40 @@
1
+ # `kerfjs/prefer-module-jsx-augmentation`
2
+
3
+ Disallow declaration-merging `JSX.IntrinsicElements` into the global namespace; use the `kerfjs/jsx-runtime` module instead.
4
+
5
+ Maps to **kerf Hard Rule 11** — kerf's JSX runtime looks up custom-element typings on its own module's `JSX` namespace. A global augmentation does not flow through to kerf's intrinsic-element table.
6
+
7
+ ## ❌ Incorrect
8
+
9
+ ```ts
10
+ declare global {
11
+ namespace JSX {
12
+ interface IntrinsicElements {
13
+ 'my-tag': { foo?: string };
14
+ }
15
+ }
16
+ }
17
+ ```
18
+
19
+ ## ✅ Correct
20
+
21
+ ```ts
22
+ declare module 'kerfjs/jsx-runtime' {
23
+ namespace JSX {
24
+ interface IntrinsicElements {
25
+ 'my-tag': KerfCustomElement & { foo?: string };
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Import the building-block types from `kerfjs/jsx-runtime`:
32
+
33
+ ```ts
34
+ import type { KerfCustomElement, KerfBaseAttrs, AttrLike } from 'kerfjs/jsx-runtime';
35
+ ```
36
+
37
+ ## What this rule does NOT catch
38
+
39
+ - Other `declare global { … }` blocks that augment things outside `JSX.IntrinsicElements` (e.g. `interface Window { … }`).
40
+ - Augmentations of `JSX.Element` or `JSX.ElementClass` inside `declare global` — only `IntrinsicElements` is reported, since that is what kerf's typed-tag-table consumes.
@@ -0,0 +1,34 @@
1
+ # `kerfjs/require-data-key-in-each`
2
+
3
+ Require `data-key` (or `id`) on the root element returned by an `each()` row render.
4
+
5
+ Maps to **kerf Hard Rule 2** — the keyed reconciler matches items by `id` first, then `data-key`. Without a key, the diff matches by position, which loses identity, focus, and cursor position on insert/delete/move.
6
+
7
+ ## ❌ Incorrect
8
+
9
+ ```tsx
10
+ each(items, (item) => <li>{item.name}</li>)
11
+
12
+ each(items, (item) => {
13
+ return <li class="row">{item.name}</li>;
14
+ })
15
+
16
+ each(items, (item) => <>{item.name}</>)
17
+ ```
18
+
19
+ ## ✅ Correct
20
+
21
+ ```tsx
22
+ each(items, (item) => <li data-key={item.id}>{item.name}</li>)
23
+
24
+ each(items, (item) => <li id={item.id}>{item.name}</li>)
25
+
26
+ // Spread attributes are conservatively allowed — they may include the key.
27
+ each(items, (item) => <li {...item.attrs}>{item.name}</li>)
28
+ ```
29
+
30
+ ## What this rule does NOT catch
31
+
32
+ - Non-inline callbacks: `each(items, renderRow)` — the rule only inspects arrow / function-expression callbacks passed directly to `each()`.
33
+ - `each` calls qualified by a namespace: `MyLib.each(items, …)` — the rule only fires on a bare `each` identifier.
34
+ - Computed key keys behind a runtime branch: if the JSX root only sometimes carries a `data-key`, the rule reports the missing static attribute. Fix by always setting it.
package/index.js ADDED
@@ -0,0 +1,37 @@
1
+ import noInlineJsxEventHandlers from './lib/rules/no-inline-jsx-event-handlers.js';
2
+ import noNestedMount from './lib/rules/no-nested-mount.js';
3
+ import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
4
+ import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
5
+
6
+ const plugin = {
7
+ meta: { name: 'eslint-plugin-kerfjs', version: '0.1.0' },
8
+ rules: {
9
+ 'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
10
+ 'require-data-key-in-each': requireDataKeyInEach,
11
+ 'no-nested-mount': noNestedMount,
12
+ 'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
13
+ },
14
+ configs: {},
15
+ };
16
+
17
+ const errorRules = {
18
+ 'kerfjs/no-inline-jsx-event-handlers': 'error',
19
+ 'kerfjs/require-data-key-in-each': 'error',
20
+ 'kerfjs/no-nested-mount': 'error',
21
+ 'kerfjs/prefer-module-jsx-augmentation': 'error',
22
+ };
23
+
24
+ // Flat config (ESLint v9+) — consumers add this object to their config array.
25
+ plugin.configs.recommended = {
26
+ plugins: { kerfjs: plugin },
27
+ rules: errorRules,
28
+ };
29
+ plugin.configs.all = plugin.configs.recommended;
30
+
31
+ // Legacy `.eslintrc` config — consumers extend `'plugin:kerfjs/legacy-recommended'`.
32
+ plugin.configs['legacy-recommended'] = {
33
+ plugins: ['kerfjs'],
34
+ rules: errorRules,
35
+ };
36
+
37
+ export default plugin;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Hard Rule 9 — kerf's JSX-to-string runtime does not support inline `onClick`-style
3
+ * event handlers. Use a `data-action` attribute and `delegate()` from the mount root.
4
+ */
5
+
6
+ const meta = {
7
+ type: 'problem',
8
+ docs: {
9
+ description:
10
+ "Disallow inline `onClick`-style JSX event handler attributes; use `data-action` + `delegate()` instead.",
11
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/no-inline-jsx-event-handlers.md',
12
+ },
13
+ schema: [],
14
+ messages: {
15
+ inline:
16
+ "Inline JSX event handler `{{name}}` is not supported by kerf's JSX-to-string runtime. Use a `data-action` attribute + `delegate()` from the mount root instead. See Hard Rule 9.",
17
+ },
18
+ };
19
+
20
+ function create(context) {
21
+ return {
22
+ JSXAttribute(node) {
23
+ const attr = node.name;
24
+ if (!attr || attr.type !== 'JSXIdentifier') return;
25
+ const name = attr.name;
26
+ if (!/^on[A-Z]/.test(name)) return;
27
+ const opening = node.parent;
28
+ if (!opening || opening.type !== 'JSXOpeningElement') return;
29
+ const elName = opening.name;
30
+ if (!elName || elName.type !== 'JSXIdentifier') return;
31
+ const first = elName.name[0];
32
+ if (first !== first.toLowerCase()) return;
33
+ context.report({ node, messageId: 'inline', data: { name } });
34
+ },
35
+ };
36
+ }
37
+
38
+ export default { meta, create };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Hard Rule 5 — one `mount()` per root. Composition is via plain functions that
3
+ * return JSX, not nested `mount()` calls.
4
+ */
5
+
6
+ function isMountCall(node) {
7
+ return (
8
+ node &&
9
+ node.type === 'CallExpression' &&
10
+ node.callee &&
11
+ node.callee.type === 'Identifier' &&
12
+ node.callee.name === 'mount'
13
+ );
14
+ }
15
+
16
+ const meta = {
17
+ type: 'problem',
18
+ docs: {
19
+ description:
20
+ "Disallow `mount()` calls nested inside another `mount()`'s render callback.",
21
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/no-nested-mount.md',
22
+ },
23
+ schema: [],
24
+ messages: {
25
+ nested:
26
+ 'Nested `mount()` is not supported — one `mount()` per root. Compose with plain functions that return JSX. See Hard Rule 5.',
27
+ },
28
+ };
29
+
30
+ function create(context) {
31
+ return {
32
+ CallExpression(node) {
33
+ if (!isMountCall(node)) return;
34
+ let p = node.parent;
35
+ while (p) {
36
+ if (
37
+ (p.type === 'ArrowFunctionExpression' || p.type === 'FunctionExpression') &&
38
+ isMountCall(p.parent) &&
39
+ p.parent.arguments.includes(p)
40
+ ) {
41
+ context.report({ node, messageId: 'nested' });
42
+ return;
43
+ }
44
+ p = p.parent;
45
+ }
46
+ },
47
+ };
48
+ }
49
+
50
+ export default { meta, create };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Hard Rule 11 — declaration-merge `JSX.IntrinsicElements` into the
3
+ * `kerfjs/jsx-runtime` module, not the global namespace. Kerf's JSX runtime
4
+ * looks up custom-element typings on its own module's JSX namespace; a global
5
+ * augmentation does not flow through.
6
+ */
7
+
8
+ function findGlobalJSXIntrinsics(moduleBlock) {
9
+ if (!moduleBlock || moduleBlock.type !== 'TSModuleBlock') return null;
10
+ for (const m of moduleBlock.body) {
11
+ if (m.type !== 'TSModuleDeclaration') continue;
12
+ const idNode = m.id;
13
+ if (!idNode || idNode.type !== 'Identifier' || idNode.name !== 'JSX') continue;
14
+ const inner = m.body;
15
+ if (!inner || inner.type !== 'TSModuleBlock') continue;
16
+ for (const member of inner.body) {
17
+ if (
18
+ member.type === 'TSInterfaceDeclaration' &&
19
+ member.id &&
20
+ member.id.type === 'Identifier' &&
21
+ member.id.name === 'IntrinsicElements'
22
+ ) {
23
+ return member;
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ const meta = {
31
+ type: 'problem',
32
+ docs: {
33
+ description:
34
+ "Declaration-merge `JSX.IntrinsicElements` into `kerfjs/jsx-runtime`, not the global namespace.",
35
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/prefer-module-jsx-augmentation.md',
36
+ },
37
+ schema: [],
38
+ messages: {
39
+ preferModule:
40
+ "Declaration-merge `JSX.IntrinsicElements` into the `kerfjs/jsx-runtime` module, not `declare global`. Use `declare module 'kerfjs/jsx-runtime' { namespace JSX { interface IntrinsicElements { ... } } }`. See Hard Rule 11.",
41
+ },
42
+ };
43
+
44
+ function create(context) {
45
+ return {
46
+ TSModuleDeclaration(node) {
47
+ if (!node.global) return;
48
+ const target = findGlobalJSXIntrinsics(node.body);
49
+ if (target) {
50
+ context.report({ node: target, messageId: 'preferModule' });
51
+ }
52
+ },
53
+ };
54
+ }
55
+
56
+ export default { meta, create };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Hard Rule 2 — `each()` rows must carry a per-item key (`data-key` or `id`).
3
+ * Without a key, the keyed reconciler matches by position and loses identity,
4
+ * focus, and cursor position on insert/delete.
5
+ */
6
+
7
+ function findRootJSXFromBody(body) {
8
+ if (!body) return null;
9
+ if (body.type === 'JSXElement' || body.type === 'JSXFragment') return body;
10
+ if (body.type === 'BlockStatement') {
11
+ for (const stmt of body.body) {
12
+ if (stmt.type === 'ReturnStatement' && stmt.argument) {
13
+ const a = stmt.argument;
14
+ if (a.type === 'JSXElement' || a.type === 'JSXFragment') return a;
15
+ return null;
16
+ }
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ const meta = {
23
+ type: 'problem',
24
+ docs: {
25
+ description:
26
+ 'Require `data-key` (or `id`) on the root element returned from `each()` row renders.',
27
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/require-data-key-in-each.md',
28
+ },
29
+ schema: [],
30
+ messages: {
31
+ missingKey:
32
+ '`each()` row root must set `data-key={...}` (or `id={...}`) per item. Without a key, the diff matches by position and loses identity, focus, and cursor position on insert/delete. See Hard Rule 2.',
33
+ fragmentRoot:
34
+ '`each()` row must produce exactly one top-level element with a `data-key`. Fragment root is not allowed (see Hard Rule 12).',
35
+ },
36
+ };
37
+
38
+ function create(context) {
39
+ return {
40
+ CallExpression(node) {
41
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'each') return;
42
+ const cb = node.arguments[1];
43
+ if (!cb) return;
44
+ if (cb.type !== 'ArrowFunctionExpression' && cb.type !== 'FunctionExpression') return;
45
+ const root = findRootJSXFromBody(cb.body);
46
+ if (!root) return;
47
+ if (root.type === 'JSXFragment') {
48
+ context.report({ node: root, messageId: 'fragmentRoot' });
49
+ return;
50
+ }
51
+ const attrs = root.openingElement.attributes;
52
+ const hasKey = attrs.some((a) => {
53
+ if (a.type === 'JSXSpreadAttribute') return true;
54
+ if (a.type !== 'JSXAttribute') return false;
55
+ const n = a.name && a.name.name;
56
+ return n === 'data-key' || n === 'id';
57
+ });
58
+ if (!hasKey) {
59
+ context.report({ node: root.openingElement, messageId: 'missingKey' });
60
+ }
61
+ },
62
+ };
63
+ }
64
+
65
+ export default { meta, create };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "eslint-plugin-kerfjs",
3
+ "version": "0.1.0",
4
+ "description": "ESLint rules that enforce kerf's hard rules — catches AI-shaped bugs at edit time.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Brian Westphal <brian.westphal@bleugris.com>",
8
+ "homepage": "https://github.com/brianwestphal/kerf/tree/main/eslint-plugin",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/brianwestphal/kerf",
12
+ "directory": "eslint-plugin"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/brianwestphal/kerf/issues"
16
+ },
17
+ "keywords": [
18
+ "eslint",
19
+ "eslintplugin",
20
+ "eslint-plugin",
21
+ "kerf",
22
+ "kerfjs"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "main": "./index.js",
28
+ "exports": {
29
+ ".": "./index.js"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "lib",
34
+ "docs",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "test": "node --test tests/rules/*.test.js"
40
+ },
41
+ "peerDependencies": {
42
+ "eslint": ">=8"
43
+ },
44
+ "devDependencies": {
45
+ "@typescript-eslint/parser": "^8.0.0",
46
+ "eslint": "^9.0.0"
47
+ }
48
+ }