eslint-plugin-kerfjs 4.4.1 → 4.5.0-beta.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 +56 -22
- package/docs/rules/ai-assistant-configs.md +9 -8
- package/docs/rules/no-inline-jsx-event-handlers.md +2 -2
- package/docs/rules/no-nested-mount.md +6 -1
- package/docs/rules/no-raw-with-dynamic-arg.md +7 -7
- package/docs/rules/prefer-attr-selector.md +7 -7
- package/docs/rules/prefer-module-jsx-augmentation.md +8 -4
- package/docs/rules/require-data-key-in-each.md +6 -6
- package/docs/rules/require-delegate-disposer.md +10 -5
- package/docs/rules/ui-composition.md +5 -0
- package/docs/rules/ui-preferences.md +5 -0
- package/docs/rules/ui-public-boundaries.md +7 -0
- package/docs/rules/ui-wiring.md +5 -0
- package/index.js +28 -1
- package/lib/rules/ai-assistant-configs.js +51 -18
- package/lib/rules/no-inline-jsx-event-handlers.js +1 -1
- package/lib/rules/no-nested-mount.js +2 -1
- package/lib/rules/no-raw-with-dynamic-arg.js +7 -6
- package/lib/rules/prefer-module-jsx-augmentation.js +3 -2
- package/lib/rules/require-data-key-in-each.js +7 -2
- package/lib/rules/require-delegate-disposer.js +2 -1
- package/lib/rules/ui-composition.js +198 -0
- package/lib/rules/ui-preferences.js +63 -0
- package/lib/rules/ui-public-boundaries.js +79 -0
- package/lib/rules/ui-wiring.js +102 -0
- package/lib/ui-contract.js +251 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,18 +7,19 @@ ESLint rules that enforce kerf's hard rules. Catches AI-shaped bugs at edit time
|
|
|
7
7
|
|
|
8
8
|
This plugin sits alongside two other defense layers shipped by [`kerfjs`](https://brianwestphal.github.io/kerf/):
|
|
9
9
|
|
|
10
|
-
| Layer
|
|
11
|
-
|
|
12
|
-
| `tsc --noEmit` with strict typings
|
|
13
|
-
| Opt-in dev-warns (`KERF_DEV_WARN_*`) | Hard Rules 4 (rebuilt listeners), 7 (untracked signals), 8 (narrow set)
|
|
14
|
-
| **This plugin**
|
|
10
|
+
| Layer | Catches | When |
|
|
11
|
+
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
|
12
|
+
| `tsc --noEmit` with strict typings | Hard Rules 8 (partial-set), most type errors | Build time |
|
|
13
|
+
| Opt-in dev-warns (`KERF_DEV_WARN_*`) | Hard Rules 4 (rebuilt listeners), 7 (untracked signals), 8 (narrow set) | Runtime |
|
|
14
|
+
| **This plugin** | Hard Rules 2, 5, 6, 10, 12 — AST-shaped antipatterns; plus rename-safety / `raw()`-audit nudges and a project-hygiene check for the bundled AI-assistant configs | **Edit time** |
|
|
15
15
|
|
|
16
|
-
All but one rule are AST-only — no `@typescript-eslint/parser`
|
|
16
|
+
All but one rule are AST-only — no `@typescript-eslint/parser` _service_ dependency is required by the plugin (consumers configure their own parser). The exception, `ai-assistant-configs`, reads the filesystem instead of the AST and runs once per lint pass.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
npm install --save-dev eslint-plugin-kerfjs
|
|
22
|
+
npm install @kerfjs/ui
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
### Supported ESLint versions
|
|
@@ -33,12 +34,12 @@ A version outside the range is not "probably fine" — it is one nobody has run.
|
|
|
33
34
|
|
|
34
35
|
```js
|
|
35
36
|
// eslint.config.js
|
|
36
|
-
import kerfjs from
|
|
37
|
-
import tsParser from
|
|
37
|
+
import kerfjs from "eslint-plugin-kerfjs";
|
|
38
|
+
import tsParser from "@typescript-eslint/parser";
|
|
38
39
|
|
|
39
40
|
export default [
|
|
40
41
|
{
|
|
41
|
-
files: [
|
|
42
|
+
files: ["**/*.ts", "**/*.tsx"],
|
|
42
43
|
languageOptions: {
|
|
43
44
|
parser: tsParser,
|
|
44
45
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
@@ -48,25 +49,58 @@ export default [
|
|
|
48
49
|
];
|
|
49
50
|
```
|
|
50
51
|
|
|
52
|
+
Applications using the UI catalog can select one of two additive flat configs:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
export default [
|
|
56
|
+
// parser/files configuration as above
|
|
57
|
+
kerfjs.configs['recommended-ui'], // preferences and wiring are warnings
|
|
58
|
+
// or kerfjs.configs['strict-ui'] // all four UI contracts are errors
|
|
59
|
+
];
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The UI rules resolve the installed `@kerfjs/ui` v1 selection catalog, v2 composition catalog, and application profile defaults. They then merge `.kerf-ui-profile.json` files from the workspace root toward the linted file. Package authors and monorepos can provide explicit artifacts without duplicating their facts:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
settings: {
|
|
66
|
+
kerfjs: {
|
|
67
|
+
ui: {
|
|
68
|
+
workspaceRoot: import.meta.dirname,
|
|
69
|
+
catalogPath: './vendor/component-catalog-v2.json',
|
|
70
|
+
selectionCatalogPath: './vendor/component-catalog.json',
|
|
71
|
+
profileDefaultsPath: './vendor/application-ui-profile.defaults.json',
|
|
72
|
+
profileContractPath: './vendor/application-ui-profile-sync.cjs',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For deterministic generated configs, the same settings accept parsed `catalog`, `selectionCatalog`, and `profile` objects. File-based precedence is package defaults, then workspace profile, then root-to-leaf directory profiles. The rules load `application-ui-profile-sync.cjs` from the installed `@kerfjs/ui` package by default because ESLint rule creation is synchronous; `profileContractPath` is only needed when artifacts are vendored. Every raw layer is schema- and catalog-reference-validated before merge, so a child cannot hide malformed or stale parent policy. Exceptions use exact `KUI-L*` diagnostic ids and repository-relative file or directory targets.
|
|
79
|
+
|
|
80
|
+
`KUI-L090` is reserved for catalog/profile loading and configuration failures; it does not collide with the static analyzer's `KUI-L001`–`KUI-L009` findings. The supported-ESLint matrix installs real packed `eslint-plugin-kerfjs` and `@kerfjs/ui` tarballs in a downstream flat-config fixture, ensuring these defaults resolve from the consumer rather than this repository's source tree.
|
|
81
|
+
|
|
51
82
|
## Legacy `.eslintrc` configs are not supported
|
|
52
83
|
|
|
53
|
-
The package is ESM-only. ESLint's `.eslintrc` system loads plugins with `require()`, which cannot load an ESM package, so `extends: ["plugin:kerfjs/legacy-recommended"]` fails with
|
|
84
|
+
The package is ESM-only. ESLint's `.eslintrc` system loads plugins with `require()`, which cannot load an ESM package, so `extends: ["plugin:kerfjs/legacy-recommended"]` fails with _"couldn't find the config to extend from"_ on ESLint 8 **and** on ESLint 9's legacy mode.
|
|
54
85
|
|
|
55
86
|
Use flat config (`eslint.config.js`), shown above. The `legacy-recommended` export still exists in the package but is unreachable through any config system — treat it as deprecated.
|
|
56
87
|
|
|
57
|
-
|
|
58
88
|
## Rules
|
|
59
89
|
|
|
60
|
-
| Rule
|
|
61
|
-
|
|
62
|
-
| [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md)
|
|
63
|
-
| [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md)
|
|
64
|
-
| [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md)
|
|
65
|
-
| [`no-nested-mount`](docs/rules/no-nested-mount.md)
|
|
66
|
-
| [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global
|
|
67
|
-
| [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md)
|
|
68
|
-
| [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md)
|
|
69
|
-
| [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md)
|
|
90
|
+
| Rule | Hard Rule | Severity (recommended) |
|
|
91
|
+
| -------------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------- |
|
|
92
|
+
| [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 10 — use `data-action` + `delegate()` | `error` |
|
|
93
|
+
| [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
|
|
94
|
+
| [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md) | 5 — capture `delegate()` disposers when scope < page | `warn` |
|
|
95
|
+
| [`no-nested-mount`](docs/rules/no-nested-mount.md) | 6 — one `mount()` per root | `error` |
|
|
96
|
+
| [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global | `error` |
|
|
97
|
+
| [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
|
|
98
|
+
| [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
|
|
99
|
+
| [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
|
|
100
|
+
| [`ui-public-boundaries`](docs/rules/ui-public-boundaries.md) | — (cataloged CSS boundaries) | `error` |
|
|
101
|
+
| [`ui-composition`](docs/rules/ui-composition.md) | — (cataloged parents and zones) | `error` |
|
|
102
|
+
| [`ui-preferences`](docs/rules/ui-preferences.md) | — (application component choices) | `warn` |
|
|
103
|
+
| [`ui-wiring`](docs/rules/ui-wiring.md) | — (required setup and cleanup) | `warn` |
|
|
70
104
|
|
|
71
105
|
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.
|
|
72
106
|
|
|
@@ -83,7 +117,7 @@ npm install
|
|
|
83
117
|
npm test
|
|
84
118
|
```
|
|
85
119
|
|
|
86
|
-
The AST rules' test suites use ESLint's `RuleTester` with `@typescript-eslint/parser`. The `ai-assistant-configs` tests are filesystem-driven — they build temp project roots with fixture `node_modules/kerfjs/ai/` bundles
|
|
120
|
+
The AST rules' test suites use ESLint's `RuleTester` with `@typescript-eslint/parser`. The `ai-assistant-configs` tests are filesystem-driven — they build temp project roots with fixture `node_modules/kerfjs/ai/` bundles, drive the classifier directly, and use the real ESLint API to prove plain lint is read-only while explicit `--fix` performs the external write.
|
|
87
121
|
|
|
88
122
|
## License
|
|
89
123
|
|
|
@@ -10,15 +10,15 @@ Maps to design doc [`docs/12-ai-assistant-configs.md`](https://github.com/brianw
|
|
|
10
10
|
|
|
11
11
|
The rule resolves `kerfjs/ai/manifest.json` from the consumer's installed `kerfjs` (silently no-ops if `kerfjs` isn't a dep). For each bundled file, it picks a trigger heuristic — "is this project using the tool this file is for?" — and only nudges the consumer when the trigger says yes:
|
|
12
12
|
|
|
13
|
-
| Bundled file
|
|
14
|
-
|
|
15
|
-
| `ai/skill.md` (Claude Code) | `.claude/` directory exists at the project root
|
|
16
|
-
| `ai/cursorrules` (Cursor)
|
|
13
|
+
| Bundled file | Triggers when | Installed to |
|
|
14
|
+
| --------------------------- | -------------------------------------------------- | ---------------------------------- |
|
|
15
|
+
| `ai/skill.md` (Claude Code) | `.claude/` directory exists at the project root | `.claude/skills/kerf-app/SKILL.md` |
|
|
16
|
+
| `ai/cursorrules` (Cursor) | `.cursorrules` file or `.cursor/` directory exists | `.cursorrules` |
|
|
17
17
|
|
|
18
18
|
A triggered file is then classified into one of three reported states (a fourth, "up-to-date," is silent):
|
|
19
19
|
|
|
20
20
|
- **Missing** — the consumer's `dest` doesn't exist.
|
|
21
|
-
- **Stale** — exists, parses cleanly,
|
|
21
|
+
- **Stale** — exists, parses cleanly, its `kerf-skill-version` line is older than the bundle's, and its canonical-section hash matches that version in the manifest's history.
|
|
22
22
|
- **Forked** — the consumer's file no longer matches the canonical layout: marker is missing, marker appears more than once, `kerf-skill-version` line is missing, or the content above the marker has been edited.
|
|
23
23
|
|
|
24
24
|
## ❌ Reported
|
|
@@ -46,7 +46,7 @@ For **missing** files, `--fix` copies the bundled canonical (`node_modules/kerfj
|
|
|
46
46
|
|
|
47
47
|
For **stale** files, `--fix` replaces only the content **above and including the `KERF-APP-CANONICAL-END` marker**. Everything below the marker — the consumer's append zone — is preserved byte-for-byte. This is the "versioned-section preservation" strategy from the design doc.
|
|
48
48
|
|
|
49
|
-
The `fix()` callback writes to a file OTHER than the linted source — unusual for an ESLint rule. ESLint
|
|
49
|
+
The `fix()` callback writes to a file OTHER than the linted source — unusual for an ESLint rule. ESLint evaluates fix callbacks during ordinary lint too, so the callback gates this external write on an explicit CLI `--fix` flag. Plain `eslint` reports the warning without touching disk; `eslint --fix` opts into installation or update.
|
|
50
50
|
|
|
51
51
|
## ❗ Forked — no auto-fix
|
|
52
52
|
|
|
@@ -55,7 +55,8 @@ If the consumer has edited the file in a way that breaks the canonical/append-zo
|
|
|
55
55
|
- **No marker present.** The file pre-dates the marker convention, the consumer deleted the marker, or the file is a hand-written variant. Auto-fixing would either clobber legitimate customizations or leave the file in an ambiguous state.
|
|
56
56
|
- **Multiple markers.** A well-formed file has exactly one. Auto-fix would have to guess which boundary is real.
|
|
57
57
|
- **No `kerf-skill-version` line.** The staleness signal is missing; we can't tell what's there.
|
|
58
|
-
- **Content above the marker has been edited.** The contract is "above the marker is kerf's; below is yours."
|
|
58
|
+
- **Content above the marker has been edited.** The contract is "above the marker is kerf's; below is yours." The rule checks stale files against the historical canonical hash for their own version, so an edited old section is preserved as a deliberate fork rather than overwritten.
|
|
59
|
+
- **Canonical history unavailable.** An old version absent from the shipped manifest history cannot be proven untouched, so the rule conservatively refuses to overwrite it.
|
|
59
60
|
|
|
60
61
|
Resolution: either restore the canonical layout (move customizations below a freshly-inserted marker, delete extras, re-add the version line) or disable the rule for this project — `'kerfjs/ai-assistant-configs': 'off'` in `eslint.config.js`.
|
|
61
62
|
|
|
@@ -77,4 +78,4 @@ Both `claude` and `cursor` default to `true`. Setting either to `false` makes th
|
|
|
77
78
|
- It does not install the configs at `npm install` time — there are no postinstall scripts. The first lint pass after `npm install kerfjs` surfaces the recommendation.
|
|
78
79
|
- It does not write to disk under plain `eslint` (no `--fix`). The warning is emitted; nothing changes on disk.
|
|
79
80
|
- It does not push Claude Code or Cursor on consumers who haven't signalled they use them — no `.claude/` or `.cursor*` ⇒ silent.
|
|
80
|
-
- It does not validate the
|
|
81
|
+
- It does not validate the _content_ of the canonical files in the bundle. That's the kerf maintainer's responsibility (and the `check:ai-bundle-in-sync` gate in the kerf repo).
|
|
@@ -40,8 +40,8 @@ delegate(rootEl, 'submit', ACTIONS.submit.selector, submit);
|
|
|
40
40
|
String literals still work for ad-hoc fixed selectors:
|
|
41
41
|
|
|
42
42
|
```tsx
|
|
43
|
-
<button data-action="save">Save</button
|
|
44
|
-
delegate(rootEl,
|
|
43
|
+
<button data-action="save">Save</button>;
|
|
44
|
+
delegate(rootEl, "click", '[data-action="save"]', save);
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
## Why this rule is AST-only
|
|
@@ -24,7 +24,12 @@ mount(bodyRoot, () => <Body />);
|
|
|
24
24
|
|
|
25
25
|
// Composition via plain functions:
|
|
26
26
|
const Header = () => <h1>{title.value}</h1>;
|
|
27
|
-
const Body = () =>
|
|
27
|
+
const Body = () => (
|
|
28
|
+
<main>
|
|
29
|
+
<Header />
|
|
30
|
+
<div>{count.value}</div>
|
|
31
|
+
</main>
|
|
32
|
+
);
|
|
28
33
|
mount(root, () => <Body />);
|
|
29
34
|
```
|
|
30
35
|
|
|
@@ -9,21 +9,21 @@ Warn when `raw()` is called with a dynamic argument (any expression that is not
|
|
|
9
9
|
## ❌ Incorrect
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
|
-
raw(userInput)
|
|
13
|
-
raw(fetchedHtml())
|
|
14
|
-
raw(`<b>${title}</b>`)
|
|
15
|
-
raw(isAdmin ? adminHtml : guestHtml)
|
|
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
16
|
```
|
|
17
17
|
|
|
18
18
|
## ✅ Correct
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
|
-
raw(
|
|
22
|
-
raw(`<p>Static template</p>`)
|
|
21
|
+
raw("<p>Static markup</p>"); // string literal — no warning
|
|
22
|
+
raw(`<p>Static template</p>`); // expression-free template literal — no warning
|
|
23
23
|
|
|
24
24
|
// Dynamic but audited — suppress with eslint-disable
|
|
25
25
|
// eslint-disable-next-line kerfjs/no-raw-with-dynamic-arg
|
|
26
|
-
raw(DOMPurify.sanitize(marked(userMarkdown)))
|
|
26
|
+
raw(DOMPurify.sanitize(marked(userMarkdown)));
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
## Why `warn` and not `error`
|
|
@@ -7,8 +7,8 @@ Prefer `attr('name', 'value').selector` over a literal `[name="value"]` selector
|
|
|
7
7
|
## ❌ Discouraged
|
|
8
8
|
|
|
9
9
|
```tsx
|
|
10
|
-
<button data-action="toggle">Toggle</button
|
|
11
|
-
delegate(root,
|
|
10
|
+
<button data-action="toggle">Toggle</button>;
|
|
11
|
+
delegate(root, "click", '[data-action="toggle"]', handler);
|
|
12
12
|
```
|
|
13
13
|
|
|
14
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.
|
|
@@ -16,14 +16,14 @@ The JSX attribute and the selector string are two independent literals; renaming
|
|
|
16
16
|
## ✅ Recommended
|
|
17
17
|
|
|
18
18
|
```tsx
|
|
19
|
-
import { attr, delegate, type AttrSpec } from
|
|
19
|
+
import { attr, delegate, type AttrSpec } from "kerfjs";
|
|
20
20
|
|
|
21
21
|
const ACTIONS = {
|
|
22
|
-
toggle: attr(
|
|
23
|
-
} as const satisfies Record<string, AttrSpec<
|
|
22
|
+
toggle: attr("data-action", "toggle"),
|
|
23
|
+
} as const satisfies Record<string, AttrSpec<"data-action">>;
|
|
24
24
|
|
|
25
|
-
<button {...ACTIONS.toggle.attrs}>Toggle</button
|
|
26
|
-
delegate(root,
|
|
25
|
+
<button {...ACTIONS.toggle.attrs}>Toggle</button>;
|
|
26
|
+
delegate(root, "click", ACTIONS.toggle.selector, handler);
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Rename `'toggle'` to `'on'` in the `ACTIONS` map and both the rendered attribute and the delegate selector update — no string-grep migration required.
|
|
@@ -10,7 +10,7 @@ Maps to **kerf Hard Rule 11** — kerf's JSX runtime looks up custom-element typ
|
|
|
10
10
|
declare global {
|
|
11
11
|
namespace JSX {
|
|
12
12
|
interface IntrinsicElements {
|
|
13
|
-
|
|
13
|
+
"my-tag": { foo?: string };
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
}
|
|
@@ -19,10 +19,10 @@ declare global {
|
|
|
19
19
|
## ✅ Correct
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
|
-
declare module
|
|
22
|
+
declare module "kerfjs/jsx-runtime" {
|
|
23
23
|
namespace JSX {
|
|
24
24
|
interface IntrinsicElements {
|
|
25
|
-
|
|
25
|
+
"my-tag": KerfCustomElement & { foo?: string };
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -31,7 +31,11 @@ declare module 'kerfjs/jsx-runtime' {
|
|
|
31
31
|
Import the building-block types from `kerfjs/jsx-runtime`:
|
|
32
32
|
|
|
33
33
|
```ts
|
|
34
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
KerfCustomElement,
|
|
36
|
+
KerfBaseAttrs,
|
|
37
|
+
AttrLike,
|
|
38
|
+
} from "kerfjs/jsx-runtime";
|
|
35
39
|
```
|
|
36
40
|
|
|
37
41
|
## What this rule does NOT catch
|
|
@@ -7,24 +7,24 @@ Maps to **kerf Hard Rule 2** — the keyed reconciler matches items by `id` firs
|
|
|
7
7
|
## ❌ Incorrect
|
|
8
8
|
|
|
9
9
|
```tsx
|
|
10
|
-
each(items, (item) => <li>{item.name}</li>)
|
|
10
|
+
each(items, (item) => <li>{item.name}</li>);
|
|
11
11
|
|
|
12
12
|
each(items, (item) => {
|
|
13
13
|
return <li class="row">{item.name}</li>;
|
|
14
|
-
})
|
|
14
|
+
});
|
|
15
15
|
|
|
16
|
-
each(items, (item) => <>{item.name}</>)
|
|
16
|
+
each(items, (item) => <>{item.name}</>);
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## ✅ Correct
|
|
20
20
|
|
|
21
21
|
```tsx
|
|
22
|
-
each(items, (item) => <li data-key={item.id}>{item.name}</li>)
|
|
22
|
+
each(items, (item) => <li data-key={item.id}>{item.name}</li>);
|
|
23
23
|
|
|
24
|
-
each(items, (item) => <li id={item.id}>{item.name}</li>)
|
|
24
|
+
each(items, (item) => <li id={item.id}>{item.name}</li>);
|
|
25
25
|
|
|
26
26
|
// Spread attributes are conservatively allowed — they may include the key.
|
|
27
|
-
each(items, (item) => <li {...item.attrs}>{item.name}</li>)
|
|
27
|
+
each(items, (item) => <li {...item.attrs}>{item.name}</li>);
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
## What this rule does NOT catch
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Require capturing the disposer returned by `delegate()` and `delegateCapture()`.
|
|
4
4
|
|
|
5
|
-
Both helpers install a single listener on the root element and return a `() => void` disposer — the
|
|
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
6
|
|
|
7
7
|
See [kerf docs §5.3 — Disposers](https://brianwestphal.github.io/kerf/docs/5-event-delegation/#53-disposers) for the full rationale.
|
|
8
8
|
|
|
@@ -49,8 +49,8 @@ Or collect into a disposer array:
|
|
|
49
49
|
```ts
|
|
50
50
|
const disposers: Array<() => void> = [];
|
|
51
51
|
disposers.push(mount(host, render));
|
|
52
|
-
disposers.push(delegate(host,
|
|
53
|
-
disposers.push(delegate(host,
|
|
52
|
+
disposers.push(delegate(host, "click", "[data-action]", onAction));
|
|
53
|
+
disposers.push(delegate(host, "keydown", "[data-edit]", onEdit));
|
|
54
54
|
|
|
55
55
|
function teardown() {
|
|
56
56
|
for (const off of disposers) off();
|
|
@@ -64,13 +64,18 @@ If the registration is attached once at module load and the root never tears dow
|
|
|
64
64
|
|
|
65
65
|
```ts
|
|
66
66
|
// 1. The `void` operator as an explicit-discard sigil.
|
|
67
|
-
void delegate(
|
|
67
|
+
void delegate(
|
|
68
|
+
document.body,
|
|
69
|
+
"click",
|
|
70
|
+
ACTIONS.inc.selector,
|
|
71
|
+
() => count.value++,
|
|
72
|
+
);
|
|
68
73
|
```
|
|
69
74
|
|
|
70
75
|
```ts
|
|
71
76
|
// 2. Standard eslint-disable for one-off cases.
|
|
72
77
|
// eslint-disable-next-line kerfjs/require-delegate-disposer
|
|
73
|
-
delegate(document.body,
|
|
78
|
+
delegate(document.body, "click", ACTIONS.inc.selector, () => count.value++);
|
|
74
79
|
```
|
|
75
80
|
|
|
76
81
|
`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.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# `ui-composition`
|
|
2
|
+
|
|
3
|
+
Enforces composition facts published by the versioned UI catalog. `KUI-L201` reports a catalog component under a known, disallowed direct parent. `KUI-L202` reports a statically visible zone child—including an intrinsic or unknown JSX element—that is not one of the zone's cataloged `accepts` entries. `KUI-L203` reports a statically provable zone cardinality violation. Toolbar's `leading`, `center`, and `trailing` validation is therefore derived from its catalog entry rather than a duplicated rule-local allowlist.
|
|
4
|
+
|
|
5
|
+
The parent check is intentionally conservative: it reports only when both child and direct parent resolve to cataloged imports. The Toolbar zone check understands fragments, arrays, and conditional/logical branches, but expressions whose content cannot be resolved statically remain runtime and accessibility work. Other catalog zone ids are not assumed to be JSX prop names. No autofix is offered because wrapping or moving UI changes structure and behavior.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# `ui-preferences`
|
|
2
|
+
|
|
3
|
+
Reports cataloged components listed in an application UI profile preference's `avoid` set and points to the package-qualified `preferred` component. The stable diagnostic is `KUI-L301`.
|
|
4
|
+
|
|
5
|
+
`recommended-ui` treats the rule as a warning; `strict-ui` treats it as an error. Use a narrowly targeted `KUI-L301` profile exception when a documented application boundary cannot follow the preference. Component substitutions are not autofixed because their props and interaction contracts can differ.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# `ui-public-boundaries`
|
|
2
|
+
|
|
3
|
+
Rejects `kui-*` classes and `--kui-*` custom properties that are not listed in the versioned composition catalog. This keeps application code out of private component anatomy while allowing cataloged styling seams.
|
|
4
|
+
|
|
5
|
+
Diagnostics are stable: `KUI-L101` identifies a private/unknown class and `KUI-L102` an unknown token. Exact profile exceptions can name either id. The rule deliberately does not rewrite selectors or tokens because there is no generally safe replacement.
|
|
6
|
+
|
|
7
|
+
The rule reads `@kerfjs/ui/ai/component-catalog-v2.json` by default. See the plugin README for alternate catalog settings used by monorepos and package authors.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# `ui-wiring`
|
|
2
|
+
|
|
3
|
+
Checks wiring obligations published by the versioned composition catalog. `KUI-L401` reports a used component whose required helper or side-effect registration import is absent. `KUI-L402` reports a helper call whose disposer is discarded.
|
|
4
|
+
|
|
5
|
+
Assign, return, or otherwise retain a disposer when the binding has a shorter lifetime than the page. A `void` call is accepted as an explicit page-lifetime choice. The rule derives valid helper sources from each v1 entry's `publicExports` and `wiring[].import` facts, so aliases, root named imports, root namespaces, wiring-subpath named imports, and wiring-subpath namespaces resolve without a second hard-coded export table. It does not attempt flow-sensitive cleanup proof and offers no autofix.
|
package/index.js
CHANGED
|
@@ -6,9 +6,13 @@ 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
8
|
import requireDelegateDisposer from './lib/rules/require-delegate-disposer.js';
|
|
9
|
+
import uiComposition from './lib/rules/ui-composition.js';
|
|
10
|
+
import uiPreferences from './lib/rules/ui-preferences.js';
|
|
11
|
+
import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
|
|
12
|
+
import uiWiring from './lib/rules/ui-wiring.js';
|
|
9
13
|
|
|
10
14
|
const plugin = {
|
|
11
|
-
meta: { name: 'eslint-plugin-kerfjs', version: '
|
|
15
|
+
meta: { name: 'eslint-plugin-kerfjs', version: '4.5.0-beta.1' },
|
|
12
16
|
rules: {
|
|
13
17
|
'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
|
|
14
18
|
'no-raw-with-dynamic-arg': noRawWithDynamicArg,
|
|
@@ -18,6 +22,10 @@ const plugin = {
|
|
|
18
22
|
'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
|
|
19
23
|
'prefer-attr-selector': preferAttrSelector,
|
|
20
24
|
'ai-assistant-configs': aiAssistantConfigs,
|
|
25
|
+
'ui-composition': uiComposition,
|
|
26
|
+
'ui-preferences': uiPreferences,
|
|
27
|
+
'ui-public-boundaries': uiPublicBoundaries,
|
|
28
|
+
'ui-wiring': uiWiring,
|
|
21
29
|
},
|
|
22
30
|
configs: {},
|
|
23
31
|
};
|
|
@@ -50,6 +58,25 @@ plugin.configs.recommended = {
|
|
|
50
58
|
plugins: { kerfjs: plugin },
|
|
51
59
|
rules: recommendedRules,
|
|
52
60
|
};
|
|
61
|
+
const recommendedUiRules = {
|
|
62
|
+
...recommendedRules,
|
|
63
|
+
'kerfjs/ui-public-boundaries': 'error',
|
|
64
|
+
'kerfjs/ui-composition': 'error',
|
|
65
|
+
'kerfjs/ui-preferences': 'warn',
|
|
66
|
+
'kerfjs/ui-wiring': 'warn',
|
|
67
|
+
};
|
|
68
|
+
plugin.configs['recommended-ui'] = {
|
|
69
|
+
plugins: { kerfjs: plugin },
|
|
70
|
+
rules: recommendedUiRules,
|
|
71
|
+
};
|
|
72
|
+
plugin.configs['strict-ui'] = {
|
|
73
|
+
plugins: { kerfjs: plugin },
|
|
74
|
+
rules: {
|
|
75
|
+
...recommendedUiRules,
|
|
76
|
+
'kerfjs/ui-preferences': 'error',
|
|
77
|
+
'kerfjs/ui-wiring': 'error',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
53
80
|
plugin.configs.all = plugin.configs.recommended;
|
|
54
81
|
|
|
55
82
|
// Legacy `.eslintrc` config — consumers extend `'plugin:kerfjs/legacy-recommended'`.
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* `KERF-APP-CANONICAL-END` marker; the consumer's append zone below the
|
|
9
9
|
* marker is preserved byte-for-byte (the "option 2" strategy from KF-217).
|
|
10
10
|
*
|
|
11
|
-
* Unusual for an ESLint rule: the `fix()` callback writes to a file
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Unusual for an ESLint rule: the `fix()` callback writes to a file OTHER than
|
|
12
|
+
* the linted source. ESLint evaluates fix callbacks during ordinary lint too,
|
|
13
|
+
* so the callback must explicitly gate that side effect on the CLI's `--fix`
|
|
14
|
+
* flag.
|
|
14
15
|
*/
|
|
15
16
|
import { createHash } from 'node:crypto';
|
|
16
17
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -105,8 +106,10 @@ export function classifyFile(file, bundleDir, cwd) {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
const markerIdx = findMarkerIndex(consumer);
|
|
108
|
-
if (markerIdx === -1)
|
|
109
|
-
|
|
109
|
+
if (markerIdx === -1)
|
|
110
|
+
return { state: 'forked', reason: 'no canonical-end marker' };
|
|
111
|
+
if (markerIdx === -2)
|
|
112
|
+
return { state: 'forked', reason: 'multiple canonical-end markers' };
|
|
110
113
|
|
|
111
114
|
const canonicalEnd = canonicalEndOffset(consumer);
|
|
112
115
|
const consumerCanonical = consumer.slice(0, canonicalEnd);
|
|
@@ -118,12 +121,26 @@ export function classifyFile(file, bundleDir, cwd) {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
const cmp = compareSemver(consumerVersion, file.version);
|
|
124
|
+
const consumerCanonicalHash = sha256(consumerCanonical);
|
|
121
125
|
if (cmp === 0) {
|
|
122
126
|
// Same version. Above-marker section should match the bundled sha256.
|
|
123
|
-
if (
|
|
127
|
+
if (consumerCanonicalHash === file.sha256) return { state: 'ok' };
|
|
124
128
|
return { state: 'forked', reason: 'content above marker has been edited' };
|
|
125
129
|
}
|
|
126
130
|
if (cmp < 0) {
|
|
131
|
+
const historicalHash = file.history?.[consumerVersion];
|
|
132
|
+
if (!historicalHash) {
|
|
133
|
+
return {
|
|
134
|
+
state: 'forked',
|
|
135
|
+
reason: `canonical hash for version ${consumerVersion} is unavailable`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (consumerCanonicalHash !== historicalHash) {
|
|
139
|
+
return {
|
|
140
|
+
state: 'forked',
|
|
141
|
+
reason: 'content above marker has been edited',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
127
144
|
return {
|
|
128
145
|
state: 'stale',
|
|
129
146
|
consumerVersion,
|
|
@@ -152,7 +169,9 @@ function loadBundle(cwd) {
|
|
|
152
169
|
function isTriggered(name, cwd) {
|
|
153
170
|
if (name === 'skill') return existsSync(join(cwd, '.claude'));
|
|
154
171
|
if (name === 'cursorrules') {
|
|
155
|
-
return
|
|
172
|
+
return (
|
|
173
|
+
existsSync(join(cwd, '.cursorrules')) || existsSync(join(cwd, '.cursor'))
|
|
174
|
+
);
|
|
156
175
|
}
|
|
157
176
|
return false;
|
|
158
177
|
}
|
|
@@ -208,11 +227,11 @@ const meta = {
|
|
|
208
227
|
stale:
|
|
209
228
|
'{{tool}} drop-in at `{{dest}}` is stale (have {{consumerVersion}}, latest is {{bundledVersion}}). Run `eslint --fix` to update the canonical section above the `KERF-APP-CANONICAL-END` marker; your customizations below the marker are preserved.',
|
|
210
229
|
forked:
|
|
211
|
-
|
|
230
|
+
"{{tool}} drop-in at `{{dest}}` is forked: {{reason}}. Restore the canonical layout (one `KERF-APP-CANONICAL-END` marker, no edits above it) or disable this rule with `'kerfjs/ai-assistant-configs': 'off'`.",
|
|
212
231
|
},
|
|
213
|
-
// Mark as fixable so
|
|
214
|
-
//
|
|
215
|
-
//
|
|
232
|
+
// Mark as fixable so the CLI exposes this rule under `--fix`. The callback
|
|
233
|
+
// writes to a separate file and returns null, so ESLint applies no edit to
|
|
234
|
+
// the linted source itself — see file header.
|
|
216
235
|
fixable: 'code',
|
|
217
236
|
};
|
|
218
237
|
|
|
@@ -225,10 +244,7 @@ function create(context) {
|
|
|
225
244
|
const claudeEnabled = options.claude !== false;
|
|
226
245
|
const cursorEnabled = options.cursor !== false;
|
|
227
246
|
// ESLint v9: `context.cwd` is a string. ESLint v8: `context.getCwd()`.
|
|
228
|
-
const cwd =
|
|
229
|
-
(typeof context.cwd === 'string' && context.cwd)
|
|
230
|
-
|| (typeof context.getCwd === 'function' && context.getCwd())
|
|
231
|
-
|| process.cwd();
|
|
247
|
+
const cwd = contextCwd(context);
|
|
232
248
|
const checked = runCheck(cwd);
|
|
233
249
|
CACHED_RESULT = checked;
|
|
234
250
|
if (!checked) return;
|
|
@@ -238,7 +254,10 @@ function create(context) {
|
|
|
238
254
|
if (file.name === 'cursorrules' && !cursorEnabled) continue;
|
|
239
255
|
if (result.state === 'ok') continue;
|
|
240
256
|
|
|
241
|
-
const tool =
|
|
257
|
+
const tool =
|
|
258
|
+
file.name === 'skill'
|
|
259
|
+
? 'Claude Code kerf-app skill'
|
|
260
|
+
: 'Cursor kerf rules';
|
|
242
261
|
const bundle = file.bundle; // e.g. 'ai/skill.md'
|
|
243
262
|
const data = { tool, dest: file.dest, bundle };
|
|
244
263
|
|
|
@@ -248,7 +267,7 @@ function create(context) {
|
|
|
248
267
|
messageId: 'missing',
|
|
249
268
|
data,
|
|
250
269
|
fix() {
|
|
251
|
-
applyFix(file, checked.bundleDir, cwd, '');
|
|
270
|
+
if (isFixCliRun()) applyFix(file, checked.bundleDir, cwd, '');
|
|
252
271
|
return null;
|
|
253
272
|
},
|
|
254
273
|
});
|
|
@@ -262,7 +281,9 @@ function create(context) {
|
|
|
262
281
|
bundledVersion: result.bundledVersion,
|
|
263
282
|
},
|
|
264
283
|
fix() {
|
|
265
|
-
|
|
284
|
+
if (isFixCliRun()) {
|
|
285
|
+
applyFix(file, checked.bundleDir, cwd, result.appendZone);
|
|
286
|
+
}
|
|
266
287
|
return null;
|
|
267
288
|
},
|
|
268
289
|
});
|
|
@@ -278,4 +299,16 @@ function create(context) {
|
|
|
278
299
|
};
|
|
279
300
|
}
|
|
280
301
|
|
|
302
|
+
function contextCwd(context) {
|
|
303
|
+
return (
|
|
304
|
+
(typeof context.cwd === 'string' && context.cwd) ||
|
|
305
|
+
(typeof context.getCwd === 'function' && context.getCwd()) ||
|
|
306
|
+
process.cwd()
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isFixCliRun() {
|
|
311
|
+
return process.argv.includes('--fix');
|
|
312
|
+
}
|
|
313
|
+
|
|
281
314
|
export default { meta, create };
|
|
@@ -7,7 +7,7 @@ const meta = {
|
|
|
7
7
|
type: 'problem',
|
|
8
8
|
docs: {
|
|
9
9
|
description:
|
|
10
|
-
|
|
10
|
+
'Disallow inline `onClick`-style JSX event handler attributes; use `data-action` + `delegate()` instead.',
|
|
11
11
|
url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/no-inline-jsx-event-handlers.md',
|
|
12
12
|
},
|
|
13
13
|
schema: [],
|
|
@@ -34,7 +34,8 @@ function create(context) {
|
|
|
34
34
|
let p = node.parent;
|
|
35
35
|
while (p) {
|
|
36
36
|
if (
|
|
37
|
-
(p.type === 'ArrowFunctionExpression' ||
|
|
37
|
+
(p.type === 'ArrowFunctionExpression' ||
|
|
38
|
+
p.type === 'FunctionExpression') &&
|
|
38
39
|
isMountCall(p.parent) &&
|
|
39
40
|
p.parent.arguments.includes(p)
|
|
40
41
|
) {
|