eslint-plugin-kerfjs 0.10.0 → 0.11.1

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,11 +58,13 @@ 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` |
62
+ | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
61
63
  | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
62
64
 
63
- 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. `ai-assistant-configs` doesn't map to a Hard Ruleit checks that the bundled kerf-app Claude Code skill / Cursor rules drop-ins are installed and current in projects that use those tools. See [`docs/12-ai-assistant-configs.md`](../docs/12-ai-assistant-configs.md) on the main kerf repo for the design.
65
+ 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 Rulesthe 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.
64
66
 
65
- ## Why these four (and not more)?
67
+ ## Why these four Hard-Rule rules (and not more)?
66
68
 
67
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.
68
70
 
@@ -14,16 +14,34 @@ Maps to **kerf Hard Rule 9** — kerf's JSX runtime renders to HTML strings, so
14
14
 
15
15
  ## ✅ Correct
16
16
 
17
+ Preferred — use `attr()` so the attribute name lives in one typed constant and
18
+ renames propagate to both JSX and `delegate()` automatically:
19
+
17
20
  ```tsx
18
- // In the template:
19
- <button data-action="save">Save</button>
20
- <input data-action="update" />
21
- <form data-action="submit">…</form>
21
+ import { attr, delegate, type AttrSpec } from 'kerfjs';
22
+
23
+ const ACTIONS = {
24
+ save: attr('data-action', 'save'),
25
+ update: attr('data-action', 'update'),
26
+ submit: attr('data-action', 'submit'),
27
+ } as const satisfies Record<string, AttrSpec<'data-action'>>;
28
+
29
+ // In the template — spread .attrs (no hardcoded 'data-action' at each call site):
30
+ <button {...ACTIONS.save.attrs}>Save</button>
31
+ <input {...ACTIONS.update.attrs} />
32
+ <form {...ACTIONS.submit.attrs}>…</form>
33
+
34
+ // Once, at module init — use .selector:
35
+ delegate(rootEl, 'click', ACTIONS.save.selector, save);
36
+ delegate(rootEl, 'input', ACTIONS.update.selector, update);
37
+ delegate(rootEl, 'submit', ACTIONS.submit.selector, submit);
38
+ ```
22
39
 
23
- // Once, at module init:
40
+ String literals still work for ad-hoc fixed selectors:
41
+
42
+ ```tsx
43
+ <button data-action="save">Save</button>
24
44
  delegate(rootEl, 'click', '[data-action="save"]', save);
25
- delegate(rootEl, 'input', '[data-action="update"]', update);
26
- delegate(rootEl, 'submit', '[data-action="submit"]', submit);
27
45
  ```
28
46
 
29
47
  ## Why this rule is AST-only
@@ -0,0 +1,41 @@
1
+ # `kerfjs/no-raw-with-dynamic-arg`
2
+
3
+ Warn when `raw()` is called with a dynamic argument (any expression that is not a static string literal or an expression-free template literal).
4
+
5
+ `raw(html)` bypasses kerf's HTML escaping and marks a string as trusted for direct DOM injection. Passing dynamic or user-controlled content is an XSS vulnerability. This rule forces an explicit `// eslint-disable-next-line` acknowledgment at every dynamic injection point, creating a searchable audit trail.
6
+
7
+ **Severity in `kerfjs.configs.recommended`: `warn`** (not `error`) because sanitized pipelines like `raw(DOMPurify.sanitize(marked(input)))` look dynamic to an AST rule but are legitimate. The warn prompts review; `eslint-disable` makes the intent explicit.
8
+
9
+ ## ❌ Incorrect
10
+
11
+ ```ts
12
+ raw(userInput) // variable reference
13
+ raw(fetchedHtml()) // function call
14
+ raw(`<b>${title}</b>`) // template literal with expressions
15
+ raw(isAdmin ? adminHtml : guestHtml) // conditional expression
16
+ ```
17
+
18
+ ## ✅ Correct
19
+
20
+ ```ts
21
+ raw('<p>Static markup</p>') // string literal — no warning
22
+ raw(`<p>Static template</p>`) // expression-free template literal — no warning
23
+
24
+ // Dynamic but audited — suppress with eslint-disable
25
+ // eslint-disable-next-line kerfjs/no-raw-with-dynamic-arg
26
+ raw(DOMPurify.sanitize(marked(userMarkdown)))
27
+ ```
28
+
29
+ ## Why `warn` and not `error`
30
+
31
+ Sanitization pipelines (`DOMPurify`, `sanitize-html`, server-rendered trusted content) are legitimate uses of `raw()` with a dynamic argument. An `error` severity would block every such callsite. `warn` surfaces the pattern for review; the `eslint-disable` suppression becomes the permanent audit marker.
32
+
33
+ ## What this rule catches
34
+
35
+ - Bare `raw(expr)` calls
36
+ - Member-expression calls `kerf.raw(expr)` and `kerfjs.raw(expr)`
37
+
38
+ ## What this rule does NOT catch
39
+
40
+ - `raw()` calls where the binding was renamed via a local alias (`const inject = raw; inject(expr)`)
41
+ - The correctness of any sanitizer passed to `raw()` — that remains the caller's responsibility
@@ -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
@@ -1,16 +1,20 @@
1
1
  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
+ import noRawWithDynamicArg from './lib/rules/no-raw-with-dynamic-arg.js';
5
+ import preferAttrSelector from './lib/rules/prefer-attr-selector.js';
4
6
  import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
5
7
  import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
6
8
 
7
9
  const plugin = {
8
- meta: { name: 'eslint-plugin-kerfjs', version: '0.9.0' },
10
+ meta: { name: 'eslint-plugin-kerfjs', version: '0.11.0' },
9
11
  rules: {
10
12
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
13
+ 'no-raw-with-dynamic-arg': noRawWithDynamicArg,
11
14
  'require-data-key-in-each': requireDataKeyInEach,
12
15
  'no-nested-mount': noNestedMount,
13
16
  'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
17
+ 'prefer-attr-selector': preferAttrSelector,
14
18
  'ai-assistant-configs': aiAssistantConfigs,
15
19
  },
16
20
  configs: {},
@@ -19,11 +23,18 @@ const plugin = {
19
23
  // Most rules ship as `error` in recommended (AST-shaped antipatterns are
20
24
  // bugs). `ai-assistant-configs` is `warn` — it's a project-hygiene nudge,
21
25
  // not a code defect, and a missing skill file shouldn't fail CI.
26
+ // `no-raw-with-dynamic-arg` is `warn` in recommended — false-positive rate is
27
+ // non-trivial (sanitized pipelines look dynamic to an AST rule), so `error`
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.
22
31
  const recommendedRules = {
23
32
  'kerfjs/no-inline-jsx-event-handlers': 'error',
33
+ 'kerfjs/no-raw-with-dynamic-arg': 'warn',
24
34
  'kerfjs/require-data-key-in-each': 'error',
25
35
  'kerfjs/no-nested-mount': 'error',
26
36
  'kerfjs/prefer-module-jsx-augmentation': 'error',
37
+ 'kerfjs/prefer-attr-selector': 'warn',
27
38
  'kerfjs/ai-assistant-configs': 'warn',
28
39
  };
29
40
 
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Flags calls to `raw(expr)` where `expr` is not a static string literal or a
3
+ * template literal with no dynamic expressions. `raw()` bypasses kerf's
4
+ * HTML auto-escaping — passing a dynamic value (user input, API response, any
5
+ * expression the user can influence) is the canonical XSS vector.
6
+ *
7
+ * Safe: raw("<strong>static</strong>")
8
+ * Safe: raw(`<em>static template</em>`) (no ${} expressions)
9
+ * Error: raw(someVariable)
10
+ * Error: raw(fetchedHtml)
11
+ * Error: raw(`<b>${userContent}</b>`) (template has expressions)
12
+ * Error: raw(marked(markdown)) (unsanitized pipeline)
13
+ *
14
+ * When the input IS user-controlled, the canonical fix is to sanitize first:
15
+ * raw(DOMPurify.sanitize(marked(userMarkdown)))
16
+ *
17
+ * To let the linter know the call is intentionally safe (e.g., a
18
+ * fully-sanitized pipeline), add an eslint-disable-next-line comment.
19
+ */
20
+
21
+ const meta = {
22
+ type: 'problem',
23
+ docs: {
24
+ description:
25
+ "Disallow `raw()` with dynamic arguments; `raw()` bypasses HTML escaping and passing dynamic values is an XSS vector.",
26
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/no-raw-with-dynamic-arg.md',
27
+ },
28
+ schema: [],
29
+ messages: {
30
+ dynamic:
31
+ "`raw()` bypasses HTML auto-escaping. Passing a dynamic value is an XSS risk unless the input has been sanitized. Sanitize first (`DOMPurify.sanitize(...)`) then pass to `raw()`, or add `// eslint-disable-next-line kerfjs/no-raw-with-dynamic-arg` to mark a call as intentionally safe.",
32
+ },
33
+ };
34
+
35
+ function isStaticArg(node) {
36
+ if (!node) return false;
37
+ // String or numeric literal: raw("html")
38
+ if (node.type === 'Literal') return typeof node.value === 'string';
39
+ // Template literal with no expressions: raw(`static`)
40
+ if (node.type === 'TemplateLiteral') return node.expressions.length === 0;
41
+ return false;
42
+ }
43
+
44
+ function create(context) {
45
+ return {
46
+ CallExpression(node) {
47
+ const callee = node.callee;
48
+ if (!callee) return;
49
+ // Match bare `raw(...)` and `kerfjs.raw(...)` / `kerf.raw(...)` shapes.
50
+ const isRaw = (callee.type === 'Identifier' && callee.name === 'raw')
51
+ || (callee.type === 'MemberExpression'
52
+ && callee.property.type === 'Identifier'
53
+ && callee.property.name === 'raw');
54
+ if (!isRaw) return;
55
+ if (node.arguments.length === 0) return;
56
+ const arg = node.arguments[0];
57
+ if (!isStaticArg(arg)) {
58
+ context.report({ node, messageId: 'dynamic' });
59
+ }
60
+ },
61
+ };
62
+ }
63
+
64
+ export default { meta, create };
@@ -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.10.0",
3
+ "version": "0.11.1",
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",