eslint-plugin-kerfjs 0.11.0 → 0.12.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
@@ -58,6 +58,7 @@ export default [
58
58
  | [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
59
59
  | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 5 — one `mount()` per root | `error` |
60
60
  | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 11 — augment `kerfjs/jsx-runtime`, not global | `error` |
61
+ | [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
61
62
  | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
62
63
  | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
63
64
 
@@ -0,0 +1,47 @@
1
+ # `kerfjs/prefer-attr-selector`
2
+
3
+ Prefer `attr('name', 'value').selector` over a literal `[name="value"]` selector when calling `delegate()` or `delegateCapture()`.
4
+
5
+ `attr()` (added in kerf 0.11) defines an action key once and exposes both `.attrs` (spread into JSX) and `.selector` (passed to `delegate()`). Routing JSX and the delegate target through one typed constant means renames stay in sync — change the value in one place and both the JSX attribute and the selector update together.
6
+
7
+ ## ❌ Discouraged
8
+
9
+ ```tsx
10
+ <button data-action="toggle">Toggle</button>
11
+ delegate(root, 'click', '[data-action="toggle"]', handler);
12
+ ```
13
+
14
+ The JSX attribute and the selector string are two independent literals; renaming the action key in JSX leaves the delegate selector unchanged, and the handler silently stops firing.
15
+
16
+ ## ✅ Recommended
17
+
18
+ ```tsx
19
+ import { attr, delegate, type AttrSpec } from 'kerfjs';
20
+
21
+ const ACTIONS = {
22
+ toggle: attr('data-action', 'toggle'),
23
+ } as const satisfies Record<string, AttrSpec<'data-action'>>;
24
+
25
+ <button {...ACTIONS.toggle.attrs}>Toggle</button>
26
+ delegate(root, 'click', ACTIONS.toggle.selector, handler);
27
+ ```
28
+
29
+ Rename `'toggle'` to `'on'` in the `ACTIONS` map and both the rendered attribute and the delegate selector update — no string-grep migration required.
30
+
31
+ ## What this rule flags
32
+
33
+ A `CallExpression` whose callee is `delegate` or `delegateCapture` (by name) and whose 3rd argument is a **simple** attribute-equals string literal: `[name="value"]` or `[name='value']`, with no compound selectors, tag prefixes, or pseudo-classes.
34
+
35
+ ## What this rule does NOT flag
36
+
37
+ These are intentionally left alone because `attr()` isn't a 1:1 swap for them:
38
+
39
+ - Class / id selectors: `'.toggle'`, `'#submit'`.
40
+ - Bare presence selectors: `'[data-new]'`, `'[data-edit]'` (no value to bind).
41
+ - Tag-qualified attribute selectors: `'button[data-action="x"]'`.
42
+ - Compound attribute selectors: `'[data-action="x"][data-id="y"]'` — for these, concatenate two `.selector` strings (`A.selector + B.selector`) or use `attr()` only for one of the legs.
43
+ - Selectors held in variables (not string literals) — already abstracted.
44
+
45
+ ## Severity
46
+
47
+ Reported as `warn` in the recommended config. The literal-selector form still works at runtime, and a one-off selector that's never shared with JSX is a legitimate use case — this rule is a nudge toward the rename-safe pattern, not a correctness bug.
package/index.js CHANGED
@@ -2,17 +2,19 @@ import aiAssistantConfigs from './lib/rules/ai-assistant-configs.js';
2
2
  import noInlineJsxEventHandlers from './lib/rules/no-inline-jsx-event-handlers.js';
3
3
  import noNestedMount from './lib/rules/no-nested-mount.js';
4
4
  import noRawWithDynamicArg from './lib/rules/no-raw-with-dynamic-arg.js';
5
+ import preferAttrSelector from './lib/rules/prefer-attr-selector.js';
5
6
  import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
6
7
  import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
7
8
 
8
9
  const plugin = {
9
- meta: { name: 'eslint-plugin-kerfjs', version: '0.9.0' },
10
+ meta: { name: 'eslint-plugin-kerfjs', version: '0.11.0' },
10
11
  rules: {
11
12
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
12
13
  'no-raw-with-dynamic-arg': noRawWithDynamicArg,
13
14
  'require-data-key-in-each': requireDataKeyInEach,
14
15
  'no-nested-mount': noNestedMount,
15
16
  'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
17
+ 'prefer-attr-selector': preferAttrSelector,
16
18
  'ai-assistant-configs': aiAssistantConfigs,
17
19
  },
18
20
  configs: {},
@@ -24,12 +26,15 @@ const plugin = {
24
26
  // `no-raw-with-dynamic-arg` is `warn` in recommended — false-positive rate is
25
27
  // non-trivial (sanitized pipelines look dynamic to an AST rule), so `error`
26
28
  // would block too many legitimate uses without eslint-disable comments.
29
+ // `prefer-attr-selector` is `warn` — the literal-selector form is still
30
+ // correct at runtime; this rule nudges toward the rename-safe pattern.
27
31
  const recommendedRules = {
28
32
  'kerfjs/no-inline-jsx-event-handlers': 'error',
29
33
  'kerfjs/no-raw-with-dynamic-arg': 'warn',
30
34
  'kerfjs/require-data-key-in-each': 'error',
31
35
  'kerfjs/no-nested-mount': 'error',
32
36
  'kerfjs/prefer-module-jsx-augmentation': 'error',
37
+ 'kerfjs/prefer-attr-selector': 'warn',
33
38
  'kerfjs/ai-assistant-configs': 'warn',
34
39
  };
35
40
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * When you call `delegate()` or `delegateCapture()` with a string-literal
3
+ * selector that matches an `[name="value"]` shape, recommend `attr()`'s
4
+ * `.selector`. Pairs JSX (`{...ACTIONS.x.attrs}`) and the delegate target
5
+ * (`ACTIONS.x.selector`) under one typed source of truth so a rename of the
6
+ * action key can't desync the two.
7
+ *
8
+ * Conservative on purpose: only flags the *exact* attribute-equals shape
9
+ * (`[data-action="save"]`, `[role="dialog"]`). Compound selectors,
10
+ * tag-qualified selectors, and class/id selectors are left alone — those
11
+ * are the cases `attr()` doesn't directly replace.
12
+ */
13
+
14
+ const meta = {
15
+ type: 'suggestion',
16
+ docs: {
17
+ description:
18
+ "Prefer `attr('name', 'value').selector` over a literal `[name=\"value\"]` selector when calling `delegate()` / `delegateCapture()`.",
19
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/prefer-attr-selector.md',
20
+ },
21
+ schema: [],
22
+ messages: {
23
+ preferAttr:
24
+ "Selector `[{{name}}=\"{{value}}\"]` is a literal string. Define `attr('{{name}}', '{{value}}')` once and pass its `.selector` here — JSX can then spread `.attrs` to stay in sync on rename. See kerf docs §5.4 (attr() helper).",
25
+ },
26
+ };
27
+
28
+ const DELEGATE_FNS = new Set(['delegate', 'delegateCapture']);
29
+
30
+ // Matches a simple attribute-equals selector: leading `[`, name, `=`, quoted
31
+ // value, trailing `]`. Anchored to the full string so compound selectors
32
+ // (`[data-action="x"][data-id="y"]`) and qualified selectors
33
+ // (`button[data-action="x"]`) do NOT match — those aren't a 1:1 attr() swap.
34
+ const ATTR_EQUALS_RE = /^\[([a-zA-Z][\w-]*)=(['"])([^'"]*)\2\]$/;
35
+
36
+ function create(context) {
37
+ return {
38
+ CallExpression(node) {
39
+ const callee = node.callee;
40
+ if (!callee || callee.type !== 'Identifier') return;
41
+ if (!DELEGATE_FNS.has(callee.name)) return;
42
+
43
+ // `delegate(root, type, selector, fn)` — selector is the 3rd arg.
44
+ const selectorArg = node.arguments[2];
45
+ if (!selectorArg || selectorArg.type !== 'Literal') return;
46
+ if (typeof selectorArg.value !== 'string') return;
47
+
48
+ const m = ATTR_EQUALS_RE.exec(selectorArg.value);
49
+ if (!m) return;
50
+
51
+ context.report({
52
+ node: selectorArg,
53
+ messageId: 'preferAttr',
54
+ data: { name: m[1], value: m[3] },
55
+ });
56
+ },
57
+ };
58
+ }
59
+
60
+ export default { meta, create };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",