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 +4 -2
- package/docs/rules/no-inline-jsx-event-handlers.md +25 -7
- package/docs/rules/no-raw-with-dynamic-arg.md +41 -0
- package/docs/rules/prefer-attr-selector.md +47 -0
- package/index.js +12 -1
- package/lib/rules/no-raw-with-dynamic-arg.js +64 -0
- package/lib/rules/prefer-attr-selector.js +60 -0
- package/package.json +1 -1
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`
|
|
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 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.
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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.
|
|
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 };
|