eslint-plugin-kerfjs 5.0.0-beta.8 → 6.0.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.
Files changed (32) hide show
  1. package/README.md +72 -21
  2. package/docs/rules/ai-assistant-configs.md +5 -5
  3. package/docs/rules/no-inline-jsx-event-handlers.md +2 -2
  4. package/docs/rules/no-nested-mount.md +6 -1
  5. package/docs/rules/no-raw-with-dynamic-arg.md +7 -7
  6. package/docs/rules/prefer-attr-selector.md +7 -7
  7. package/docs/rules/prefer-module-jsx-augmentation.md +8 -4
  8. package/docs/rules/require-data-key-in-each.md +6 -6
  9. package/docs/rules/require-delegate-disposer.md +10 -5
  10. package/docs/rules/ui-component-ownership.md +43 -0
  11. package/docs/rules/ui-composition.md +60 -0
  12. package/docs/rules/ui-css-values.md +24 -0
  13. package/docs/rules/ui-preferences.md +16 -0
  14. package/docs/rules/ui-public-boundaries.md +48 -0
  15. package/docs/rules/ui-wiring.md +27 -0
  16. package/index.js +34 -1
  17. package/lib/html-template-classes.js +104 -0
  18. package/lib/rules/ai-assistant-configs.js +19 -9
  19. package/lib/rules/no-inline-jsx-event-handlers.js +1 -1
  20. package/lib/rules/no-nested-mount.js +2 -1
  21. package/lib/rules/no-raw-with-dynamic-arg.js +7 -6
  22. package/lib/rules/prefer-module-jsx-augmentation.js +3 -2
  23. package/lib/rules/require-data-key-in-each.js +7 -2
  24. package/lib/rules/require-delegate-disposer.js +2 -1
  25. package/lib/rules/ui-component-ownership.js +199 -0
  26. package/lib/rules/ui-composition.js +279 -0
  27. package/lib/rules/ui-css-values.js +243 -0
  28. package/lib/rules/ui-preferences.js +71 -0
  29. package/lib/rules/ui-public-boundaries.js +192 -0
  30. package/lib/rules/ui-wiring.js +287 -0
  31. package/lib/ui-contract.js +577 -0
  32. package/package.json +1 -1
package/README.md CHANGED
@@ -7,13 +7,13 @@ 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 | 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** |
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` *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.
16
+ The core syntax rules analyze the AST without TypeScript parser services. `ai-assistant-configs` reads the filesystem, and the optional UI rules also resolve catalog and profile artifacts. Consumers configure their own parser; no `@typescript-eslint/parser` _service_ is required.
17
17
 
18
18
  ## Install
19
19
 
@@ -21,6 +21,9 @@ All but one rule are AST-only — no `@typescript-eslint/parser` *service* depen
21
21
  npm install --save-dev eslint-plugin-kerfjs
22
22
  ```
23
23
 
24
+ Install `@kerfjs/ui` as well when using `recommended-ui` or `strict-ui`; the
25
+ core `recommended` preset does not require it.
26
+
24
27
  ### Supported ESLint versions
25
28
 
26
29
  `peerDependencies` declares `^9.0.0 || ^10.0.0`, and that is a **tested** range rather than an optimistic one: the rule suite runs against the latest release of each of those majors in CI on every push. A major is added to the range only after the suite has passed on it.
@@ -33,12 +36,12 @@ A version outside the range is not "probably fine" — it is one nobody has run.
33
36
 
34
37
  ```js
35
38
  // eslint.config.js
36
- import kerfjs from 'eslint-plugin-kerfjs';
37
- import tsParser from '@typescript-eslint/parser';
39
+ import kerfjs from "eslint-plugin-kerfjs";
40
+ import tsParser from "@typescript-eslint/parser";
38
41
 
39
42
  export default [
40
43
  {
41
- files: ['**/*.ts', '**/*.tsx'],
44
+ files: ["**/*.ts", "**/*.tsx"],
42
45
  languageOptions: {
43
46
  parser: tsParser,
44
47
  parserOptions: { ecmaFeatures: { jsx: true } },
@@ -48,25 +51,72 @@ export default [
48
51
  ];
49
52
  ```
50
53
 
54
+ Applications using the UI catalog can select one of two additive flat configs:
55
+
56
+ ```js
57
+ export default [
58
+ // parser/files configuration as above
59
+ kerfjs.configs['recommended-ui'], // preferences and wiring are warnings
60
+ // or kerfjs.configs['strict-ui'] // all UI contracts are errors
61
+ ];
62
+ ```
63
+
64
+ The UI rules resolve the installed `@kerfjs/ui` selection catalog (`component-catalog.json`), composition catalog (`component-composition.json`), 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:
65
+
66
+ ```js
67
+ settings: {
68
+ kerfjs: {
69
+ ui: {
70
+ workspaceRoot: import.meta.dirname,
71
+ catalogPath: './vendor/component-composition.json',
72
+ selectionCatalogPath: './vendor/component-catalog.json',
73
+ profileDefaultsPath: './vendor/application-ui-profile.defaults.json',
74
+ profileContractPath: './vendor/application-ui-profile-sync.cjs',
75
+ },
76
+ },
77
+ }
78
+ ```
79
+
80
+ 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, and then its directory must also hold `application-ui-diagnostic-ids-v1.json` and `component-class-owners.cjs` (the class-to-component rule `KUI-L103` shares with `@kerfjs/ui`'s generated component pages) from the same `@kerfjs/ui` release. 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.
81
+
82
+ `ui-css-values` consumes each catalog entry's property-specific grammar and
83
+ emits `KUI-L013`–`KUI-L017` with a preferred shorthand or helper. It covers JSX,
84
+ direct component calls, nested catalog paths, and explicitly configured
85
+ third-party catalogs; dynamic values remain governed by TypeScript.
86
+
87
+ `ui-component-ownership` keeps code out of another package's component
88
+ internals: `KUI-L020` reports a reference to a component's private `--_*`
89
+ variable and `KUI-L021` an assignment to a token a typed prop sets (use the
90
+ prop). Each message ends by routing a real need with no prop to a component-gap
91
+ report instead of an override. `kerf-ui-analyze` reports the same ids, plus the
92
+ stylesheet-only `KUI-L019` / `KUI-L022`, for CSS files.
93
+
94
+ `KUI-L090` is reserved for catalog/profile loading and configuration failures. 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.
95
+
51
96
  ## Legacy `.eslintrc` configs are not supported
52
97
 
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 *"couldn't find the config to extend from"* on ESLint 8 **and** on ESLint 9's legacy mode.
98
+ 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
99
 
55
100
  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
101
 
57
-
58
102
  ## Rules
59
103
 
60
- | Rule | Hard Rule | Severity (recommended) |
61
- |---|---|---|
62
- | [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 10 — use `data-action` + `delegate()` | `error` |
63
- | [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
64
- | [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md) | 5 — capture `delegate()` disposers when scope < page | `warn` |
65
- | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 6 — one `mount()` per root | `error` |
66
- | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global | `error` |
67
- | [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
68
- | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
69
- | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
104
+ | Rule | Hard Rule | Severity (recommended) |
105
+ | -------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------- |
106
+ | [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 10 — use `data-action` + `delegate()` | `error` |
107
+ | [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
108
+ | [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md) | 5 — capture `delegate()` disposers when scope < page | `warn` |
109
+ | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 6 — one `mount()` per root | `error` |
110
+ | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global | `error` |
111
+ | [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
112
+ | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
113
+ | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
114
+ | [`ui-public-boundaries`](docs/rules/ui-public-boundaries.md) | — (cataloged classes/tokens; component anatomy on app elements) | `error` |
115
+ | [`ui-component-ownership`](docs/rules/ui-component-ownership.md) | — (configure components, never override them) | `error` |
116
+ | [`ui-composition`](docs/rules/ui-composition.md) | — (cataloged parents and zones) | `error` |
117
+ | [`ui-css-values`](docs/rules/ui-css-values.md) | — (cataloged property-specific value grammar) | `error` |
118
+ | [`ui-preferences`](docs/rules/ui-preferences.md) | — (application component choices) | `warn` |
119
+ | [`ui-wiring`](docs/rules/ui-wiring.md) | — (required setup and cleanup) | `warn` |
70
120
 
71
121
  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
122
 
@@ -81,6 +131,7 @@ When a real bug ships that the existing defense stack misses AND a new lint rule
81
131
  ```bash
82
132
  npm install
83
133
  npm test
134
+ npm run test:eslint-matrix # real packed-plugin loading on every supported ESLint major
84
135
  ```
85
136
 
86
137
  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.
@@ -10,10 +10,10 @@ 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 | 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` |
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
 
@@ -78,4 +78,4 @@ Both `claude` and `cursor` default to `true`. Setting either to `false` makes th
78
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.
79
79
  - It does not write to disk under plain `eslint` (no `--fix`). The warning is emitted; nothing changes on disk.
80
80
  - It does not push Claude Code or Cursor on consumers who haven't signalled they use them — no `.claude/` or `.cursor*` ⇒ silent.
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).
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, 'click', '[data-action="save"]', save);
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 = () => <main><Header /><div>{count.value}</div></main>;
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) // variable reference
13
- raw(fetchedHtml()) // function call
14
- raw(`<b>${title}</b>`) // template literal with expressions
15
- raw(isAdmin ? adminHtml : guestHtml) // conditional expression
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('<p>Static markup</p>') // string literal — no warning
22
- raw(`<p>Static template</p>`) // expression-free template literal — no warning
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, 'click', '[data-action="toggle"]', handler);
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 'kerfjs';
19
+ import { attr, delegate, type AttrSpec } from "kerfjs";
20
20
 
21
21
  const ACTIONS = {
22
- toggle: attr('data-action', 'toggle'),
23
- } as const satisfies Record<string, AttrSpec<'data-action'>>;
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, 'click', ACTIONS.toggle.selector, handler);
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
- 'my-tag': { foo?: string };
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 'kerfjs/jsx-runtime' {
22
+ declare module "kerfjs/jsx-runtime" {
23
23
  namespace JSX {
24
24
  interface IntrinsicElements {
25
- 'my-tag': KerfCustomElement & { foo?: string };
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 { KerfCustomElement, KerfBaseAttrs, AttrLike } from 'kerfjs/jsx-runtime';
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 *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.
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, 'click', '[data-action]', onAction));
53
- disposers.push(delegate(host, 'keydown', '[data-edit]', onEdit));
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(document.body, 'click', ACTIONS.inc.selector, () => count.value++);
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, 'click', ACTIONS.inc.selector, () => count.value++);
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,43 @@
1
+ # `ui-component-ownership`
2
+
3
+ Components own their styles and are configured, never overridden. This rule
4
+ keeps application and component-package code from reaching into another
5
+ package's component through its variables:
6
+
7
+ - `KUI-L020` — a string, template, object key, or `style` value references a
8
+ component's private `--_<root>-*` variable (`--_kui-list-gap`), or any
9
+ `--_kui-*` variable. Private variables are implementation; reading or writing
10
+ them breaks when the component changes.
11
+ - `KUI-L021` — code assigns a component token that a typed prop sets, in a
12
+ `style` string (`style="--kui-list-gap: 4px"`), a style object key
13
+ (`style={{ '--kui-floating-toolbar-inset': '0' }}`), or
14
+ `style.setProperty('--kui-disclosure-arrow-size', …)`. Set the prop instead
15
+ (`<List gap="xs" />`).
16
+
17
+ Both messages name the owning component and end with the same guidance: if no
18
+ configuration covers the need, report the component gap to the owning package
19
+ (open a feature request) instead of overriding it.
20
+
21
+ ```tsx
22
+ // KUI-L020 / KUI-L021
23
+ <div style="--_kui-list-gap: 2px; --kui-list-gap: 4px">…</div>;
24
+
25
+ // Configure the component instead.
26
+ <List gap="xs">…</List>;
27
+ ```
28
+
29
+ Ownership comes from the loaded composition catalogs, so third-party component
30
+ packages declared in `.kerf-ui-profile.json` get the same protection. A file
31
+ inside the package that owns a component (the nearest `package.json` names the
32
+ catalog entry's `package`) may use that component's own private variables and
33
+ tokens. Reading a public token (`var(--kui-list-divider-color)`) and setting a
34
+ theme token with no equivalent prop remain configuration and are not reported.
35
+
36
+ CSS files are outside ESLint's reach: `kerf-ui-analyze` (and `kerf-ui-doctor`)
37
+ report the same `KUI-L020` / `KUI-L021` ids in stylesheets, plus `KUI-L019`
38
+ (a rule whose subject is a cataloged component) and `KUI-L022` (a hook class
39
+ on a component's root). See `@kerfjs/ui/docs/ui-analyzer.md`.
40
+
41
+ Exact profile exceptions may name either id. The rule reads
42
+ `@kerfjs/ui/ai/component-composition.json` by default; see the plugin README for
43
+ alternate catalog settings.
@@ -0,0 +1,60 @@
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. A zone participates only when it declares an explicit `jsx.prop` binding, including `children`; the rule never guesses that a zone id is a JSX prop. Toolbar, Pane, TabBar, AppTab, feedback, search, and layout validation is therefore catalog-driven rather than duplicated in rule-local component cases.
4
+
5
+ The parent check is intentionally conservative about the child: it runs only for a child that resolves to a cataloged entry (or a declared wrapper's roots) whose parents are `listed`. Its direct JSX parent may be any element the rule can see: a cataloged component, a declared wrapper (which counts as each of its roots), an intrinsic element such as `<div>`, or an unresolved component, and any parent that is not a listed parent reports `KUI-L201`. Bound zone checks understand aliases, namespace imports, fragments, arrays, and conditional/logical branches, but expressions whose content cannot be resolved statically remain runtime and accessibility work. Accepted concepts that do not identify a catalog entry remain machine-readable guidance while the rule enforces their statically provable cardinality only. No autofix is offered because wrapping or moving UI changes structure and behavior.
6
+ Toolbar zones accept `KerfUiContent`, so the zone rule allows direct JSX
7
+ controls and custom components there. Their own parent contracts still apply,
8
+ including when a direct child is supplied through a zone prop.
9
+
10
+ An additional JSX export from an entry can declare its own `jsxExports.<name>.parents` contract. It uses that placement instead of the entry's root placement and is opaque to zone matching, so an action link does not impersonate its enclosing control group.
11
+
12
+ `KUI-L202` inspects JSX written directly in a bound zone prop, including its
13
+ fragments, arrays, and conditional branches. It does not follow a local binding
14
+ such as `const actions = <div />; <TabBar trailing={actions} />`. Review those
15
+ bindings against the zone's catalog contract; an unknown expression produces
16
+ no zone-child diagnostic.
17
+
18
+ ## Application and third-party wrappers
19
+
20
+ Beyond `@kerfjs/ui`'s own catalog, the rule loads every composition catalog the
21
+ resolved `.kerf-ui-profile.json` declares under `catalogs`. Their components
22
+ resolve by package subpath (`import { GroupWrap } from '@acme/bits/group-wrap'`,
23
+ matched to the entry's `publicExports`) or, for an application's own relative
24
+ imports, by source file (matched to the entry's `source`, trying the TypeScript
25
+ ESM extensions a `.js` specifier stands for). An export without a `subpath` (a
26
+ private application's, which is never imported by package name) resolves only
27
+ by source file.
28
+
29
+ A relative import may also reach the source file through relative re-exports,
30
+ the way apps import wrappers through a components barrel:
31
+ `import { DemandSegmentsControl } from './components/index.js'` resolves when
32
+ `index.ts` has `export { DemandSegmentsControl } from '../demand-segments-control.js'`,
33
+ a renamed `export { X as Y } from …` (import `Y`), or `export * from …`, and
34
+ through chains of them (a directory import tries its `index` file). Type-only
35
+ and namespace (`export * as ns`) re-exports, a component defined in the barrel
36
+ itself, and re-exports of anything the catalogs do not declare stay unresolved,
37
+ and cycles stop without resolving.
38
+
39
+ An entry that declares `rendersAs` (a wrapper that renders one of the listed
40
+ cataloged roots, or nothing) is checked as those roots:
41
+
42
+ - **Zones (`KUI-L202`):** the zone must accept every declared root.
43
+ - **Parents (`KUI-L201`):** each root's listed parents apply where the wrapper
44
+ is placed, and a wrapper used as a parent counts as each of its roots. A
45
+ placement reports once, using the first violated root requirement before a
46
+ separate wrapper requirement if both apply.
47
+ - **Cardinality (`KUI-L203`):** the wrapper counts as zero or one child, since
48
+ it may render nothing.
49
+
50
+ ```tsx
51
+ import { Toolbar } from '@kerfjs/ui';
52
+ import { DemandSegmentsControl } from './demand-segments-control.js';
53
+
54
+ // Valid when the app's catalog declares
55
+ // rendersAs: ["@kerfjs/ui:toolbar-control-group"].
56
+ <Toolbar trailing={<DemandSegmentsControl />} />;
57
+ ```
58
+
59
+ A wrapper without `rendersAs`, or one the profile's catalogs do not declare,
60
+ is still an unknown element and reports `KUI-L202` in a cataloged zone.
@@ -0,0 +1,24 @@
1
+ # `ui-css-values`
2
+
3
+ Checks literal JSX and direct component-function calls against each component's
4
+ cataloged `cssValueProps` contracts. The catalog keeps length, size, flex,
5
+ color, declaration-list, media-query, and numeric-pixel grammars distinct and
6
+ names their accepted helpers, finite shorthands, and raw-value policy.
7
+
8
+ Stable diagnostics are `KUI-L013` (raw or unknown shorthand), `KUI-L014`
9
+ (wrong property helper), `KUI-L015` (non-standalone expression), `KUI-L016`
10
+ (forbidden declaration list), and review-oriented `KUI-L017` (exceptional
11
+ spacing shorthand). Messages name the preferred shorthand or helper. Dynamic
12
+ values are left to TypeScript rather than guessed.
13
+
14
+ Only component exports carry an entry's contracts. A catalog entry's
15
+ `publicExports` also lists the helpers its props consume (`Select` ships
16
+ `uiColor`, `List` ships `px`, `rem`, and `flex`); a helper call such as
17
+ `flex({ gap: '12px' })` is not a `List` call and is never checked as one. A
18
+ component export is one whose name starts with an uppercase letter, the rule
19
+ JSX itself uses to tell a component tag from an intrinsic element; a default
20
+ import keeps its subpath's entry. `@kerfjs/ui`'s catalog check enforces the
21
+ convention for the package, and `kerf-ui-analyze` applies the same rule.
22
+
23
+ Third-party component suites can provide the same contracts in a composition
24
+ catalog through `settings.kerfjs.ui.catalog` and its matching selection catalog.
@@ -0,0 +1,16 @@
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
+ A component matches whether it is rendered through an imported export (`<WaButtonGroup />`) or written directly as its custom-element tag (`<wa-dropdown>`), which resolves through the selection catalog's `customElement`. The `@kerfjs/ui` package defaults avoid every `Discouraged` Web Awesome element, so each of these reports and names the Kerf component to render instead:
6
+
7
+ ```tsx
8
+ <wa-dropdown>…</wa-dropdown> // KUI-L301: prefer @kerfjs/ui:popup-menu
9
+ <wa-select>…</wa-select> // KUI-L301: prefer @kerfjs/ui:select
10
+ <wa-tab-group>…</wa-tab-group> // KUI-L301: prefer @kerfjs/ui:tab-bar
11
+ <wa-icon name="gear" /> // KUI-L301: prefer @kerfjs/ui:lucide-icon
12
+ ```
13
+
14
+ Supported Web Awesome elements (`<wa-button>`, `<wa-dialog>`, the encouraged `<wa-popup>`) and unknown custom elements are not reported. The source file of a component declared in a profile catalog is exempt: that wrapper owns the element it renders, and the preference applies to its callers.
15
+
16
+ `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,48 @@
1
+ # `ui-public-boundaries`
2
+
3
+ Rejects `kui-*` classes and `--kui-*` custom properties that are not listed in the versioned composition catalog, and cataloged component classes written onto elements the application owns. 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, `KUI-L102` an unknown token, and `KUI-L103` a component's anatomy class placed on an application-owned element. Exact profile exceptions can name any of these ids. The rule deliberately does not rewrite selectors, tokens, or markup because there is no generally safe replacement.
6
+
7
+ ## Component anatomy on your own element (`KUI-L103`)
8
+
9
+ A public class such as `kui-toolbar` or `kui-pane__content` is the rendered anatomy of a component. Writing it onto your own element recreates the component by class: the element inherits (and invites you to restyle) the component's CSS without its props, behavior, accessibility, or future fixes. Components own their styles; render and configure them instead.
10
+
11
+ ```tsx
12
+ <div class="kui-toolbar">…</div> // KUI-L103: render `Toolbar`
13
+ <aside class="kui-pane"><nav class="kui-pane__content"> // KUI-L103: render `Pane`
14
+ <wa-dropdown class="kui-popup-menu">…</wa-dropdown> // KUI-L103: render `PopupMenu`
15
+ <Text class="kui-toolbar-text" /> // KUI-L103: render `ToolbarText`
16
+ <div class="kui-content-item">…</div> // KUI-L103: render `ContentItem`
17
+ ```
18
+
19
+ The catalog marks the classes an application may place with `boundaries.placeableClasses`; these stay allowed:
20
+
21
+ ```tsx
22
+ <div id="app" class="kui-app-root" /> // document root
23
+ <main class="kui-content kui-scroll-owner" /> // layout utilities
24
+ <footer class="kui-control-cluster kui-content-item" /> // item geometry on a non-div carrier
25
+ <Toolbar className="kui-toolbar" /> // a component's own element
26
+ ```
27
+
28
+ A placeable class can still be a component's own root. When a catalog entry names the `boundaries.rootElement` its component renders around its placeable classes, a plain element of that tag carrying them is the component recreated and is reported: `<div class="kui-content-item">` is exactly what `ContentItem` renders. The class stays placeable on any other carrier — a `<ul>`, a `Text`, or a control-cluster `<footer>` that must carry the geometry itself. `@kerfjs/ui`'s own `check:guidance` derives its repository check from the same catalog fields, so the two cannot disagree.
29
+
30
+ The component a diagnostic names is the export named after the class's block, tried from the whole block down to its last word (`kui-toolbar-action-link` → `ToolbarActionLink`, `kui-pane__content` → `Pane`, `acme-meter__bar` → `Meter`), else the entry's own component, else every component the entry exports. That rule ships once, as `@kerfjs/ui/ai/component-class-owners.cjs`; the rule loads it beside `application-ui-profile-sync.cjs`, and `@kerfjs/ui`'s generated `ai/components/*.md` pages render their "render X instead" lines from the same file, so the lint message and the component reference always name the same component. An `@kerfjs/ui` too old to ship it fails the contract load (`KUI-L090`) rather than guessing.
31
+
32
+ ## Component packages
33
+
34
+ `KUI-L103` is not limited to `kui-*` classes. Every application or third-party composition catalog your `.kerf-ui-profile.json` declares under `catalogs` contributes its entries' public classes the same way: an `acme-meter` class on your own `<div>` is reported with the package's component to render (`Meter`), its `boundaries.placeableClasses` stay allowed, and its `boundaries.rootElement` reports a placeable class on a plain element of the tag the component renders. Component packages declare these fields in `kerf.components.json`; see the component-packages guide (`docs/13-component-packages.md` §13.7). The first catalog to list a class owns it, so `@kerfjs/ui`'s catalog wins over a declared one. `KUI-L101` (private/unknown class) still applies only to `kui-*` names.
35
+
36
+ String class values are inspected in `class` / `className` literals and in a template literal's static, whitespace-delimited names (``class={`kui-content-item ${extra}`}``); a name an interpolation completes (`` `kui-toolbar-${size}` ``) is not known and is skipped.
37
+
38
+ The no-build `kerfjs/html` tagged template writes the same markup without JSX, so its `class` attributes carry the same `KUI-L101` / `KUI-L103` contract. The rule reads the template's static parts by the runtime's own rules: a complete static value (`class="kui-pane"`, `class='…'`, or unquoted) counts, tag and attribute names are case-insensitive, and the element is the tag the markup names, so a plain `<div class="kui-content-item">` is `ContentItem` recreated there too. A hole is a whole attribute value (`class=${cls}`), so the class it supplies is unknown and skipped; comments, text, and tags or attributes whose name is a hole are not inspected. Only the `html` tag imported from `kerfjs/html` (by name, alias, or namespace) is read.
39
+
40
+ ```ts
41
+ import { html } from 'kerfjs/html';
42
+ html`<aside class="kui-pane">…</aside>`; // KUI-L103: render `Pane`
43
+ html`<div class="kui-content-item">…</div>`; // KUI-L103: render `ContentItem`
44
+ html`<ul class="kui-content-item">…</ul>`; // allowed: placeable on a <ul>
45
+ html`<aside class=${paneClass}>…</aside>`; // not inspected: the value is a hole
46
+ ```
47
+
48
+ The rule reads `@kerfjs/ui/ai/component-composition.json` by default. See the plugin README for alternate catalog settings used by monorepos and package authors.
@@ -0,0 +1,27 @@
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
+ Declare application entry modules in the application UI profile to check root wiring once per entry:
6
+
7
+ ```json
8
+ { "schemaVersion": 1, "scope": "workspace", "wiring": { "entries": ["src/main.tsx"] } }
9
+ ```
10
+
11
+ Entry paths are relative to the workspace root. The rule follows relative imports, re-exports, and literal dynamic imports from each entry. An application-scoped obligation is checked at the entry if any reachable module renders its component; a recognized helper call in any reachable module satisfies it. Reachable leaf modules need no duplicate wiring. A module outside every declared entry keeps the per-file check. A composition entry can set `wiring.scope` to `"module"` for a helper that must be installed beside its rendering code; omitted scope means `"application"`. Computed import paths and unresolved modules stay outside the static graph, so put them in a declared entry or check them separately. `KUI-L402` still checks the helper's call site.
12
+
13
+ Consumer composition catalogs can name an application helper's defining file in `wiring.sources`:
14
+
15
+ ```json
16
+ "wiring": {
17
+ "required": true,
18
+ "helpers": ["wireTicketSearch"],
19
+ "sources": [{ "export": "wireTicketSearch", "source": "src/interactions/ticket-search.ts" }]
20
+ }
21
+ ```
22
+
23
+ The source path is relative to the catalog package root. The rule resolves relative imports, aliases, and re-export barrels to that file, so another function with the same name does not satisfy the obligation.
24
+
25
+ The selection catalog can declare a public wiring provider in `wiringProviders`, naming its export, import source, and the helpers it installs. A recognized `wireCatalog` call from `@kerfjs/ui/wire-catalog` provides `wireScrollDividers`, so the rule does not ask for a duplicate call on the same application entry. The provider's disposer still needs to be retained.
26
+
27
+ 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 component-catalog 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.