eslint-plugin-kerfjs 0.9.1 → 0.11.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,11 +58,12 @@ 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
+ | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
61
62
  | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
62
63
 
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.
64
+ 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
65
 
65
- ## Why these four (and not more)?
66
+ ## Why these four Hard-Rule rules (and not more)?
66
67
 
67
68
  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
69
 
@@ -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
package/index.js CHANGED
@@ -1,6 +1,7 @@
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';
4
5
  import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
5
6
  import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
6
7
 
@@ -8,6 +9,7 @@ const plugin = {
8
9
  meta: { name: 'eslint-plugin-kerfjs', version: '0.9.0' },
9
10
  rules: {
10
11
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
12
+ 'no-raw-with-dynamic-arg': noRawWithDynamicArg,
11
13
  'require-data-key-in-each': requireDataKeyInEach,
12
14
  'no-nested-mount': noNestedMount,
13
15
  'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
@@ -19,8 +21,12 @@ const plugin = {
19
21
  // Most rules ship as `error` in recommended (AST-shaped antipatterns are
20
22
  // bugs). `ai-assistant-configs` is `warn` — it's a project-hygiene nudge,
21
23
  // not a code defect, and a missing skill file shouldn't fail CI.
24
+ // `no-raw-with-dynamic-arg` is `warn` in recommended — false-positive rate is
25
+ // non-trivial (sanitized pipelines look dynamic to an AST rule), so `error`
26
+ // would block too many legitimate uses without eslint-disable comments.
22
27
  const recommendedRules = {
23
28
  'kerfjs/no-inline-jsx-event-handlers': 'error',
29
+ 'kerfjs/no-raw-with-dynamic-arg': 'warn',
24
30
  'kerfjs/require-data-key-in-each': 'error',
25
31
  'kerfjs/no-nested-mount': 'error',
26
32
  'kerfjs/prefer-module-jsx-augmentation': 'error',
@@ -39,6 +39,13 @@ function resolveManifestPath(cwd) {
39
39
  const req = createRequire(join(cwd, 'noop.js'));
40
40
  return req.resolve('kerfjs/ai/manifest.json');
41
41
  } catch {
42
+ // Fallback: kerfjs versions before the `./ai/*` exports entry landed
43
+ // block subpath resolution under Node's strict `exports` rules
44
+ // (ERR_PACKAGE_PATH_NOT_EXPORTED). Look directly at the package on disk —
45
+ // good enough for the plain-npm layout, and the rule should fire for
46
+ // those installs too rather than silently no-op.
47
+ const direct = join(cwd, 'node_modules', 'kerfjs', 'ai', 'manifest.json');
48
+ if (existsSync(direct)) return direct;
42
49
  return null;
43
50
  }
44
51
  }
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "0.9.1",
3
+ "version": "0.11.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",