eslint-plugin-kerfjs 0.12.0 → 0.13.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 CHANGED
@@ -54,19 +54,20 @@ export default [
54
54
 
55
55
  | Rule | Hard Rule | Severity (recommended) |
56
56
  |---|---|---|
57
- | [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 9 — use `data-action` + `delegate()` | `error` |
57
+ | [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 10 — use `data-action` + `delegate()` | `error` |
58
58
  | [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
59
- | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 5 — one `mount()` per root | `error` |
60
- | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 11augment `kerfjs/jsx-runtime`, not global | `error` |
59
+ | [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md) | 5 — capture `delegate()` disposers when scope < page | `warn` |
60
+ | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 6one `mount()` per root | `error` |
61
+ | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global | `error` |
61
62
  | [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
62
63
  | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
63
64
  | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
64
65
 
65
66
  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. `no-raw-with-dynamic-arg` and `ai-assistant-configs` don't map to numbered Hard Rules — the former creates an audit trail for every dynamic `raw()` call site (potential XSS); the latter checks that the bundled AI-assistant configs are installed and current. See [`docs/12-ai-assistant-configs.md`](../docs/12-ai-assistant-configs.md) on the main kerf repo for the AI-configs design.
66
67
 
67
- ## Why these four Hard-Rule rules (and not more)?
68
+ ## Why these five Hard-Rule rules (and not more)?
68
69
 
69
- 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.
70
+ Rules that need flow analysis (signal reads outside render — Rule 8), call-graph analysis (`addEventListener` inside the mount tree — Rule 4), or type information (partial-set against multi-key state — Rule 9) 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.
70
71
 
71
72
  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.
72
73
 
@@ -0,0 +1,93 @@
1
+ # `kerfjs/require-delegate-disposer`
2
+
3
+ Require capturing the disposer returned by `delegate()` and `delegateCapture()`.
4
+
5
+ Both helpers install a single listener on the root element and return a `() => void` disposer — the *only* way to remove the listener once it's attached. Discarding the return value is safe in exactly one case: the registration is genuinely page-lifetime (root is `document.body` or another never-torn-down element, attached once at startup, never re-registered). In every other case the listener closure pins `rootEl`, `handler`, and everything the handler closes over (stores, signals, app state) — so an undisposed delegate on a transient root leaks both the listener and the app graph it references, and re-mount cycles stack listeners linearly. `mount()`'s own disposer does NOT remove delegates for you.
6
+
7
+ See [kerf docs §5.3 — Disposers](https://brianwestphal.github.io/kerf/docs/5-event-delegation/#53-disposers) for the full rationale.
8
+
9
+ ## ❌ Discouraged
10
+
11
+ ```ts
12
+ // Transient root: the modal is mounted on open and torn down on close, but
13
+ // the delegate listener (and its closure over stores) stays attached forever.
14
+ function openModal(host: HTMLElement) {
15
+ mount(host, () => <ModalView />);
16
+ delegate(host, 'click', '[data-action]', handleAction); // disposer discarded
17
+ }
18
+ ```
19
+
20
+ ```ts
21
+ // Re-mount cycle: every htmx swap re-attaches mount + delegate, but the
22
+ // previous delegate's listener never gets removed. Listener count grows
23
+ // linearly with swap count.
24
+ function onSwap(initial: CartItem[]) {
25
+ mount(root, () => <Cart items={items} />);
26
+ delegate(root, 'click', '.remove', handleRemove); // disposer discarded
27
+ }
28
+ ```
29
+
30
+ ## ✅ Recommended
31
+
32
+ Capture and call the disposer alongside the mount teardown:
33
+
34
+ ```ts
35
+ function openModal(host: HTMLElement) {
36
+ const stopMount = mount(host, () => <ModalView />);
37
+ const stopDelegate = delegate(host, 'click', '[data-action]', handleAction);
38
+
39
+ return function closeModal() {
40
+ stopMount();
41
+ stopDelegate();
42
+ host.remove();
43
+ };
44
+ }
45
+ ```
46
+
47
+ Or collect into a disposer array:
48
+
49
+ ```ts
50
+ const disposers: Array<() => void> = [];
51
+ disposers.push(mount(host, render));
52
+ disposers.push(delegate(host, 'click', '[data-action]', onAction));
53
+ disposers.push(delegate(host, 'keydown', '[data-edit]', onEdit));
54
+
55
+ function teardown() {
56
+ for (const off of disposers) off();
57
+ disposers.length = 0;
58
+ }
59
+ ```
60
+
61
+ ## When the registration really is page-lifetime
62
+
63
+ If the registration is attached once at module load and the root never tears down (`document.body`, a never-removed app shell), discarding the disposer matches the intent. Two opt-outs:
64
+
65
+ ```ts
66
+ // 1. The `void` operator as an explicit-discard sigil.
67
+ void delegate(document.body, 'click', ACTIONS.inc.selector, () => count.value++);
68
+ ```
69
+
70
+ ```ts
71
+ // 2. Standard eslint-disable for one-off cases.
72
+ // eslint-disable-next-line kerfjs/require-delegate-disposer
73
+ delegate(document.body, 'click', ACTIONS.inc.selector, () => count.value++);
74
+ ```
75
+
76
+ `void` is the lower-friction option when the file has many page-lifetime registrations clustered together; eslint-disable carries the rule name with it, which makes it easier to grep for.
77
+
78
+ ## What the rule does and doesn't see
79
+
80
+ The rule flags `delegate(...)` or `delegateCapture(...)` whose immediate parent is an `ExpressionStatement` — i.e. the call is the entire statement and nothing consumes its return value. Any non-statement parent is accepted: assignments (`const off = …`), returns (`return …`), array elements (`[…, delegate(…), …]`), object properties (`{ off: delegate(…) }`), call arguments (`onCleanup(delegate(…))`), the `void` operator, etc.
81
+
82
+ What the rule does NOT do:
83
+
84
+ - It does not verify that the captured disposer is actually called somewhere. `const off = delegate(...)` followed by never calling `off()` still passes the rule — that's cross-function flow analysis and out of scope.
85
+ - It does not track imports. The rule matches by callee name (`delegate` / `delegateCapture`). A local function with the same name will trigger the rule; suppress with `eslint-disable` or rename the local.
86
+ - It does not look at `mount()` disposers. `mount()` has the same lifecycle property but is out of scope for this rule.
87
+
88
+ ## When to suppress
89
+
90
+ - **Genuinely page-lifetime registration** — use `void delegate(...)` or `// eslint-disable-next-line kerfjs/require-delegate-disposer`.
91
+ - **A local `delegate()` function unrelated to kerf** — rename it or suppress per-file with a directive.
92
+
93
+ Don't suppress because "it's only a small leak" or "we'll add disposal later." The cost of capturing the disposer is one variable assignment; the cost of a slow leak that compounds across user actions is much higher.
package/index.js CHANGED
@@ -5,13 +5,15 @@ import noRawWithDynamicArg from './lib/rules/no-raw-with-dynamic-arg.js';
5
5
  import preferAttrSelector from './lib/rules/prefer-attr-selector.js';
6
6
  import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
7
7
  import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
8
+ import requireDelegateDisposer from './lib/rules/require-delegate-disposer.js';
8
9
 
9
10
  const plugin = {
10
- meta: { name: 'eslint-plugin-kerfjs', version: '0.11.0' },
11
+ meta: { name: 'eslint-plugin-kerfjs', version: '0.13.0' },
11
12
  rules: {
12
13
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
13
14
  'no-raw-with-dynamic-arg': noRawWithDynamicArg,
14
15
  'require-data-key-in-each': requireDataKeyInEach,
16
+ 'require-delegate-disposer': requireDelegateDisposer,
15
17
  'no-nested-mount': noNestedMount,
16
18
  'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
17
19
  'prefer-attr-selector': preferAttrSelector,
@@ -28,10 +30,15 @@ const plugin = {
28
30
  // would block too many legitimate uses without eslint-disable comments.
29
31
  // `prefer-attr-selector` is `warn` — the literal-selector form is still
30
32
  // correct at runtime; this rule nudges toward the rename-safe pattern.
33
+ // `require-delegate-disposer` is `warn` — the discarded-disposer form is
34
+ // still correct at runtime for page-lifetime roots, and downstream code
35
+ // that's been around since before the rule shipped needs a deprecation
36
+ // window to audit. Promote to `error` after one or two releases.
31
37
  const recommendedRules = {
32
38
  'kerfjs/no-inline-jsx-event-handlers': 'error',
33
39
  'kerfjs/no-raw-with-dynamic-arg': 'warn',
34
40
  'kerfjs/require-data-key-in-each': 'error',
41
+ 'kerfjs/require-delegate-disposer': 'warn',
35
42
  'kerfjs/no-nested-mount': 'error',
36
43
  'kerfjs/prefer-module-jsx-augmentation': 'error',
37
44
  'kerfjs/prefer-attr-selector': 'warn',
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `delegate()` and `delegateCapture()` return a `() => void` disposer that
3
+ * removes the underlying root listener. Discarding the return value is only
4
+ * safe when the registration is genuinely page-lifetime (root never torn down,
5
+ * handler closure references no reclaimable state). In every other case —
6
+ * modal mounts, route views, mount swaps, dynamic widgets — discarding the
7
+ * disposer leaks the listener AND every store / signal / app object the
8
+ * handler closes over, and re-mount cycles stack listeners linearly.
9
+ *
10
+ * This rule flags the bare-statement form (`delegate(...);`) where the
11
+ * disposer is silently discarded. Accept any non-statement parent: assignment,
12
+ * return, array push, argument to another call, member expression, etc. — all
13
+ * of those carry the disposer somewhere a future scope can call it.
14
+ *
15
+ * Explicit-discard escape hatch: `void delegate(...)`. That signals "I know
16
+ * this is page-lifetime and I'm intentionally not capturing." Standard
17
+ * `eslint-disable-next-line` works too.
18
+ *
19
+ * Matched by callee name (`delegate` / `delegateCapture`) — consistent with
20
+ * the other kerfjs rules. False positives on an unrelated local `delegate()`
21
+ * function exist in principle; suppress with the standard mechanism.
22
+ */
23
+
24
+ const meta = {
25
+ type: 'problem',
26
+ docs: {
27
+ description:
28
+ 'Require capturing the disposer returned by `delegate()` / `delegateCapture()`. Discarding the return value leaks the listener and everything the handler closes over.',
29
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/require-delegate-disposer.md',
30
+ },
31
+ schema: [],
32
+ messages: {
33
+ requireDisposer:
34
+ "`{{fn}}()` returns a `() => void` disposer that must be captured and called when the delegate's scope ends. Discarding it leaks the listener (and every store/signal the handler closes over) and stacks listeners on re-mount. Assign it (`const off = {{fn}}(...)`), return it, or push it into a disposer array. If the registration is genuinely page-lifetime (root is `document.body` or equivalent, never torn down), opt out with `void {{fn}}(...)` or `eslint-disable-next-line kerfjs/require-delegate-disposer`. See kerf docs §5.3.",
35
+ },
36
+ };
37
+
38
+ const DELEGATE_FNS = new Set(['delegate', 'delegateCapture']);
39
+
40
+ function create(context) {
41
+ return {
42
+ CallExpression(node) {
43
+ const callee = node.callee;
44
+ if (!callee || callee.type !== 'Identifier') return;
45
+ if (!DELEGATE_FNS.has(callee.name)) return;
46
+
47
+ // Walk up through any wrappers that don't actually consume the value.
48
+ // The only case we need to handle specially is `void delegate(...)` —
49
+ // an explicit discard sigil that opts out of the rule. Every other
50
+ // parent shape (VariableDeclarator, ReturnStatement, ArrayExpression,
51
+ // Property, CallExpression argument, MemberExpression, etc.) means
52
+ // the disposer is being routed somewhere reachable — accept.
53
+ const parent = node.parent;
54
+ if (!parent) return;
55
+ if (parent.type === 'UnaryExpression' && parent.operator === 'void') return;
56
+ if (parent.type !== 'ExpressionStatement') return;
57
+
58
+ context.report({
59
+ node,
60
+ messageId: 'requireDisposer',
61
+ data: { fn: callee.name },
62
+ });
63
+ },
64
+ };
65
+ }
66
+
67
+ export default { meta, create };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "ESLint rules that enforce kerf's hard rules — catches AI-shaped bugs at edit time.",
5
5
  "type": "module",
6
6
  "license": "MIT",