eslint-plugin-kerfjs 5.0.0-beta.56 → 5.0.0-beta.58

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
@@ -13,15 +13,17 @@ This plugin sits alongside two other defense layers shipped by [`kerfjs`](https:
13
13
  | Opt-in dev-warns (`KERF_DEV_WARN_*`) | Hard Rules 4 (rebuilt listeners), 7 (untracked signals), 8 (narrow set) | Runtime |
14
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
 
20
20
  ```bash
21
21
  npm install --save-dev eslint-plugin-kerfjs
22
- npm install @kerfjs/ui
23
22
  ```
24
23
 
24
+ Install `@kerfjs/ui` as well when using `recommended-ui` or `strict-ui`; the
25
+ core `recommended` preset does not require it.
26
+
25
27
  ### Supported ESLint versions
26
28
 
27
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.
@@ -59,14 +61,14 @@ export default [
59
61
  ];
60
62
  ```
61
63
 
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:
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:
63
65
 
64
66
  ```js
65
67
  settings: {
66
68
  kerfjs: {
67
69
  ui: {
68
70
  workspaceRoot: import.meta.dirname,
69
- catalogPath: './vendor/component-catalog-v2.json',
71
+ catalogPath: './vendor/component-composition.json',
70
72
  selectionCatalogPath: './vendor/component-catalog.json',
71
73
  profileDefaultsPath: './vendor/application-ui-profile.defaults.json',
72
74
  profileContractPath: './vendor/application-ui-profile-sync.cjs',
@@ -75,13 +77,20 @@ settings: {
75
77
  }
76
78
  ```
77
79
 
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.
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.
79
81
 
80
82
  `ui-css-values` consumes each catalog entry's property-specific grammar and
81
83
  emits `KUI-L013`–`KUI-L017` with a preferred shorthand or helper. It covers JSX,
82
84
  direct component calls, nested catalog paths, and explicitly configured
83
85
  third-party catalogs; dynamic values remain governed by TypeScript.
84
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
+
85
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.
86
95
 
87
96
  ## Legacy `.eslintrc` configs are not supported
@@ -92,21 +101,22 @@ Use flat config (`eslint.config.js`), shown above. The `legacy-recommended` expo
92
101
 
93
102
  ## Rules
94
103
 
95
- | Rule | Hard Rule | Severity (recommended) |
96
- | -------------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------- |
97
- | [`no-inline-jsx-event-handlers`](docs/rules/no-inline-jsx-event-handlers.md) | 10 — use `data-action` + `delegate()` | `error` |
98
- | [`require-data-key-in-each`](docs/rules/require-data-key-in-each.md) | 2 — `data-key` per item | `error` |
99
- | [`require-delegate-disposer`](docs/rules/require-delegate-disposer.md) | 5 — capture `delegate()` disposers when scope < page | `warn` |
100
- | [`no-nested-mount`](docs/rules/no-nested-mount.md) | 6 — one `mount()` per root | `error` |
101
- | [`prefer-module-jsx-augmentation`](docs/rules/prefer-module-jsx-augmentation.md) | 12 — augment `kerfjs/jsx-runtime`, not global | `error` |
102
- | [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
103
- | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
104
- | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
105
- | [`ui-public-boundaries`](docs/rules/ui-public-boundaries.md) | — (cataloged CSS boundaries) | `error` |
106
- | [`ui-composition`](docs/rules/ui-composition.md) | — (cataloged parents and zones) | `error` |
107
- | [`ui-css-values`](docs/rules/ui-css-values.md) | — (cataloged property-specific value grammar) | `error` |
108
- | [`ui-preferences`](docs/rules/ui-preferences.md) | — (application component choices) | `warn` |
109
- | [`ui-wiring`](docs/rules/ui-wiring.md) | — (required setup and cleanup) | `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` |
110
120
 
111
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.
112
122
 
@@ -121,6 +131,7 @@ When a real bug ships that the existing defense stack misses AND a new lint rule
121
131
  ```bash
122
132
  npm install
123
133
  npm test
134
+ npm run test:eslint-matrix # real packed-plugin loading on every supported ESLint major
124
135
  ```
125
136
 
126
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.
@@ -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.
@@ -2,4 +2,15 @@
2
2
 
3
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
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
+
5
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.
@@ -1,7 +1,48 @@
1
1
  # `ui-public-boundaries`
2
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.
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
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.
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
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.
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.
@@ -2,4 +2,4 @@
2
2
 
3
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
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.
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 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.
package/index.js CHANGED
@@ -6,6 +6,7 @@ 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 uiComponentOwnership from './lib/rules/ui-component-ownership.js';
9
10
  import uiComposition from './lib/rules/ui-composition.js';
10
11
  import uiCssValues from './lib/rules/ui-css-values.js';
11
12
  import uiPreferences from './lib/rules/ui-preferences.js';
@@ -13,7 +14,7 @@ import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
13
14
  import uiWiring from './lib/rules/ui-wiring.js';
14
15
 
15
16
  const plugin = {
16
- meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.56' },
17
+ meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.58' },
17
18
  rules: {
18
19
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
19
20
  'no-raw-with-dynamic-arg': noRawWithDynamicArg,
@@ -23,6 +24,7 @@ const plugin = {
23
24
  'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
24
25
  'prefer-attr-selector': preferAttrSelector,
25
26
  'ai-assistant-configs': aiAssistantConfigs,
27
+ 'ui-component-ownership': uiComponentOwnership,
26
28
  'ui-composition': uiComposition,
27
29
  'ui-css-values': uiCssValues,
28
30
  'ui-preferences': uiPreferences,
@@ -63,6 +65,7 @@ plugin.configs.recommended = {
63
65
  const recommendedUiRules = {
64
66
  ...recommendedRules,
65
67
  'kerfjs/ui-public-boundaries': 'error',
68
+ 'kerfjs/ui-component-ownership': 'error',
66
69
  'kerfjs/ui-composition': 'error',
67
70
  'kerfjs/ui-css-values': 'error',
68
71
  'kerfjs/ui-preferences': 'warn',
@@ -0,0 +1,104 @@
1
+ // The `class` attributes a `kerfjs/html` tagged template writes, read from its
2
+ // static parts. It follows the runtime's static-part rules
3
+ // (`src/utils/parseTemplate.ts`): markup is text, open tags, and comments; a
4
+ // `${…}` hole is a whole attribute value (`class=${x}` / `class="${x}"`), so
5
+ // a class it supplies is unknown and skipped. A complete static value
6
+ // (`class="kui-pane"`) counts in full; when a hole interrupts a value (which
7
+ // the runtime rejects as a partial value) only the whitespace-delimited names
8
+ // the static text completes count, as for a JSX template literal. Tags whose
9
+ // name is a hole, and holes inside comments, are not markup and are skipped.
10
+
11
+ const HOLE = '\0';
12
+
13
+ const isSpace = (ch) => /\s/.test(ch);
14
+
15
+ // `{ quasi, tag, value }` for every `class` attribute, where `quasi` is the
16
+ // index of the static part the attribute name starts in, `tag` the lowercased
17
+ // element name, and `value` the attribute value with each hole as `\0`.
18
+ export function htmlTemplateClassAttributes(strings) {
19
+ const text = strings.join(HOLE);
20
+ // Offset where each static part starts in `text`.
21
+ const starts = [];
22
+ let offset = 0;
23
+ for (const part of strings) {
24
+ starts.push(offset);
25
+ offset += part.length + 1;
26
+ }
27
+ const quasiAt = (position) => {
28
+ let index = 0;
29
+ while (index + 1 < starts.length && starts[index + 1] <= position)
30
+ index += 1;
31
+ return index;
32
+ };
33
+ const attributes = [];
34
+ let i = 0;
35
+ while (i < text.length) {
36
+ if (text[i] !== '<') {
37
+ i += 1;
38
+ continue;
39
+ }
40
+ if (text.startsWith('<!--', i)) {
41
+ const end = text.indexOf('-->', i + 4);
42
+ i = end === -1 ? text.length : end + 3;
43
+ continue;
44
+ }
45
+ if (!/[a-zA-Z]/.test(text[i + 1] ?? '')) {
46
+ // A closing tag, `<!DOCTYPE`, `<?…`, or a tag-name hole: skip it whole.
47
+ if (/[!/?]/.test(text[i + 1] ?? '')) {
48
+ const end = text.indexOf('>', i + 1);
49
+ i = end === -1 ? text.length : end + 1;
50
+ } else i += 1;
51
+ continue;
52
+ }
53
+ let j = i + 1;
54
+ while (j < text.length && !/[\s/>]/.test(text[j]) && text[j] !== HOLE)
55
+ j += 1;
56
+ const tag = text.slice(i + 1, j).toLowerCase();
57
+ // A hole completing the tag name (`<div${x}>`) makes it unknown.
58
+ const knownTag = text[j] !== HOLE;
59
+ // Attributes until the tag closes.
60
+ while (j < text.length && text[j] !== '>') {
61
+ if (isSpace(text[j]) || text[j] === '/' || text[j] === HOLE) {
62
+ j += 1;
63
+ continue;
64
+ }
65
+ const nameStart = j;
66
+ while (j < text.length && !/[\s"'<>/=]/.test(text[j]) && text[j] !== HOLE)
67
+ j += 1;
68
+ if (j === nameStart) {
69
+ // A stray quote or `=` with no name; step past it.
70
+ j += 1;
71
+ continue;
72
+ }
73
+ const name = text.slice(nameStart, j).toLowerCase();
74
+ // A hole completing the name (`data-${x}=…`) makes it unknown.
75
+ const knownName = text[j] !== HOLE;
76
+ let k = j;
77
+ while (k < text.length && isSpace(text[k])) k += 1;
78
+ if (text[k] !== '=') continue;
79
+ k += 1;
80
+ while (k < text.length && isSpace(text[k])) k += 1;
81
+ let value;
82
+ if (text[k] === '"' || text[k] === "'") {
83
+ const end = text.indexOf(text[k], k + 1);
84
+ value = text.slice(k + 1, end === -1 ? text.length : end);
85
+ j = end === -1 ? text.length : end + 1;
86
+ } else {
87
+ let end = k;
88
+ while (end < text.length && !/[\s>]/.test(text[end])) end += 1;
89
+ value = text.slice(k, end);
90
+ j = end;
91
+ }
92
+ if (knownTag && knownName && name === 'class')
93
+ attributes.push({ quasi: quasiAt(nameStart), tag, value });
94
+ }
95
+ i = j + 1;
96
+ }
97
+ return attributes;
98
+ }
99
+
100
+ // The class names a value states outright: whitespace-delimited names no hole
101
+ // touches.
102
+ export function staticClassNames(value) {
103
+ return value.split(/\s+/).filter((name) => name && !name.includes(HOLE));
104
+ }
@@ -0,0 +1,199 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+
4
+ import {
5
+ isExcepted,
6
+ loadUiContract,
7
+ UI_CONTRACT_LOAD_CODE,
8
+ UI_RULE_SCHEMA,
9
+ } from '../ui-contract.js';
10
+
11
+ const PRIVATE_CODE = 'KUI-L020';
12
+ const TYPED_CODE = 'KUI-L021';
13
+
14
+ const kebab = (value) =>
15
+ value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
16
+
17
+ // The same catalog-derived facts `kerf-ui-analyze` uses for KUI-L020 /
18
+ // KUI-L021 (`@kerfjs/ui/analyzer/component-ownership.mjs`): every root-like
19
+ // public class names a component, `--_<root>-*` is that component's private
20
+ // variable, and `--<root>-<prop>` is the token its typed `<prop>` sets.
21
+ const factsCache = new WeakMap();
22
+ function ownershipFacts(contract) {
23
+ if (factsCache.has(contract)) return factsCache.get(contract);
24
+ const privatePrefixes = [];
25
+ const typedTokens = new Map();
26
+ for (const entry of contract.entries.values()) {
27
+ const boundaries = entry.boundaries ?? {};
28
+ const roots = [
29
+ ...new Set(
30
+ [boundaries.rootClass, ...(boundaries.publicClasses ?? [])].filter(
31
+ (name) => name && !name.includes('__') && !name.includes('--'),
32
+ ),
33
+ ),
34
+ ];
35
+ const tokens = new Set(boundaries.publicTokens ?? []);
36
+ for (const root of roots) {
37
+ privatePrefixes.push({ prefix: `--_${root}`, entry });
38
+ for (const contractProp of entry.cssValueProps ?? []) {
39
+ const [head] = contractProp.path.split('[].');
40
+ const token = `--${root}-${kebab(head)}`;
41
+ if (tokens.has(token) && !typedTokens.has(token))
42
+ typedTokens.set(token, { entry, path: contractProp.path });
43
+ }
44
+ }
45
+ }
46
+ privatePrefixes.sort((a, b) => b.prefix.length - a.prefix.length);
47
+ const facts = { privatePrefixes, typedTokens };
48
+ factsCache.set(contract, facts);
49
+ return facts;
50
+ }
51
+
52
+ const packageNames = new Map();
53
+ function ownPackage(filename) {
54
+ for (
55
+ let directory = dirname(resolve(filename));
56
+ ;
57
+ directory = dirname(directory)
58
+ ) {
59
+ if (packageNames.has(directory)) return packageNames.get(directory);
60
+ const manifest = resolve(directory, 'package.json');
61
+ if (existsSync(manifest)) {
62
+ let name;
63
+ try {
64
+ name = JSON.parse(readFileSync(manifest, 'utf8')).name;
65
+ } catch {
66
+ name = undefined;
67
+ }
68
+ if (typeof name === 'string') {
69
+ packageNames.set(directory, name);
70
+ return name;
71
+ }
72
+ }
73
+ if (dirname(directory) === directory) return undefined;
74
+ }
75
+ }
76
+
77
+ const gap = (entry) =>
78
+ `If no configuration covers this need, report the component gap to ${entry?.package ?? '@kerfjs/ui'} (open a feature request) instead of overriding it.`;
79
+
80
+ export default {
81
+ meta: {
82
+ type: 'problem',
83
+ docs: {
84
+ description:
85
+ "Keep code from reaching into a Kerf UI component's private variables or overriding a token its typed prop configures.",
86
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-component-ownership.md',
87
+ },
88
+ schema: UI_RULE_SCHEMA,
89
+ messages: {
90
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
91
+ private: `${PRIVATE_CODE}: \`{{name}}\` is {{owner}}'s private variable; components own their styles. Configure it through its typed props, variants, or public tokens. {{gap}}`,
92
+ typed: `${TYPED_CODE}: \`{{name}}\` is what {{component}}'s typed \`{{prop}}\` prop sets; set the prop on {{component}} instead of overriding the token. {{gap}}`,
93
+ },
94
+ },
95
+ create(context) {
96
+ const contract = loadUiContract(context);
97
+ const filename = context.filename ?? context.getFilename();
98
+ let configured = false;
99
+ if (contract.error)
100
+ return {
101
+ Program(node) {
102
+ if (configured) return;
103
+ configured = true;
104
+ context.report({
105
+ node,
106
+ messageId: 'config',
107
+ data: { error: contract.error },
108
+ });
109
+ },
110
+ };
111
+ const facts = ownershipFacts(contract);
112
+ const own = ownPackage(filename);
113
+ const checkPrivate = !isExcepted(contract, PRIVATE_CODE, filename);
114
+ const checkTyped = !isExcepted(contract, TYPED_CODE, filename);
115
+
116
+ const privateOwner = (name) => {
117
+ const match = facts.privatePrefixes.find(
118
+ ({ prefix }) => name === prefix || name.startsWith(`${prefix}-`),
119
+ );
120
+ if (match) return match.entry;
121
+ return name.startsWith('--_kui-') ? null : undefined;
122
+ };
123
+ const reportPrivate = (node, text) => {
124
+ if (!checkPrivate) return;
125
+ for (const [name] of text.matchAll(/--_[a-z0-9_-]+/gi)) {
126
+ const owner = privateOwner(name);
127
+ if (owner === undefined || (owner && owner.package === own)) continue;
128
+ context.report({
129
+ node,
130
+ messageId: 'private',
131
+ data: {
132
+ name,
133
+ owner: owner ? owner.name : 'a Kerf component',
134
+ gap: gap(owner),
135
+ },
136
+ });
137
+ }
138
+ };
139
+ const reportTyped = (node, token) => {
140
+ if (!checkTyped) return;
141
+ const typed = facts.typedTokens.get(token);
142
+ if (!typed || typed.entry.package === own) return;
143
+ context.report({
144
+ node,
145
+ messageId: 'typed',
146
+ data: {
147
+ name: token,
148
+ component: typed.entry.name,
149
+ prop: typed.path,
150
+ gap: gap(typed.entry),
151
+ },
152
+ });
153
+ };
154
+ const inspectText = (node, text) => {
155
+ if (typeof text !== 'string') return;
156
+ reportPrivate(node, text);
157
+ // `--token: value` inside a style string assigns the token.
158
+ for (const [, token] of text.matchAll(/(--[a-z0-9-]+)\s*:/gi))
159
+ reportTyped(node, token);
160
+ };
161
+ const keyName = (property) =>
162
+ property.computed
163
+ ? property.key.type === 'Literal'
164
+ ? property.key.value
165
+ : undefined
166
+ : (property.key.name ?? property.key.value);
167
+
168
+ return {
169
+ Literal(node) {
170
+ if (node.parent?.type === 'Property' && node.parent.key === node)
171
+ return;
172
+ inspectText(node, node.value);
173
+ },
174
+ TemplateElement(node) {
175
+ inspectText(node, node.value.raw);
176
+ },
177
+ // `style={{ '--kui-list-gap': … }}` and any object keyed by a token.
178
+ Property(node) {
179
+ const key = keyName(node);
180
+ if (typeof key !== 'string' || !key.startsWith('--')) return;
181
+ reportPrivate(node.key, key);
182
+ reportTyped(node.key, key);
183
+ },
184
+ // `element.style.setProperty('--kui-list-gap', …)`.
185
+ CallExpression(node) {
186
+ const callee = node.callee;
187
+ const [first] = node.arguments;
188
+ if (
189
+ callee.type === 'MemberExpression' &&
190
+ !callee.computed &&
191
+ callee.property.name === 'setProperty' &&
192
+ first?.type === 'Literal' &&
193
+ typeof first.value === 'string'
194
+ )
195
+ reportTyped(first, first.value);
196
+ },
197
+ };
198
+ },
199
+ };
@@ -1,4 +1,7 @@
1
+ import { resolve } from 'node:path';
2
+
1
3
  import {
4
+ customElementKey,
2
5
  importRegistry,
3
6
  isExcepted,
4
7
  jsxKey,
@@ -39,7 +42,12 @@ export default {
39
42
  },
40
43
  JSXOpeningElement(node) {
41
44
  if (!registry || isExcepted(contract, CODE, filename)) return;
42
- const actual = jsxKey(node.name, registry, contract);
45
+ // A declared component's own source is the wrapper that owns the
46
+ // element it renders; the preference governs its callers.
47
+ if (contract.componentSources.has(resolve(filename))) return;
48
+ const actual =
49
+ jsxKey(node.name, registry, contract) ??
50
+ customElementKey(node.name, contract);
43
51
  if (!actual) return;
44
52
  for (const [concept, preference] of Object.entries(
45
53
  contract.profile.preferences ?? {},
@@ -1,12 +1,37 @@
1
1
  import {
2
+ importRegistry,
2
3
  isExcepted,
4
+ jsxKey,
3
5
  loadUiContract,
4
6
  UI_CONTRACT_LOAD_CODE,
5
7
  UI_RULE_SCHEMA,
6
8
  } from '../ui-contract.js';
9
+ import {
10
+ htmlTemplateClassAttributes,
11
+ staticClassNames,
12
+ } from '../html-template-classes.js';
7
13
 
8
14
  const CLASS_CODE = 'KUI-L101';
9
15
  const TOKEN_CODE = 'KUI-L102';
16
+ const PLACEMENT_CODE = 'KUI-L103';
17
+
18
+ const HTML_SOURCE = 'kerfjs/html';
19
+
20
+ // The `html` tag imported from `kerfjs/html`, by name or through a namespace.
21
+ function isHtmlTag(tag, registry) {
22
+ if (tag.type === 'Identifier') {
23
+ const helper = registry.helpers.get(tag.name);
24
+ return helper?.imported === 'html' && helper.source === HTML_SOURCE;
25
+ }
26
+ return (
27
+ tag.type === 'MemberExpression' &&
28
+ !tag.computed &&
29
+ tag.object.type === 'Identifier' &&
30
+ tag.property.type === 'Identifier' &&
31
+ tag.property.name === 'html' &&
32
+ registry.namespaces.get(tag.object.name) === HTML_SOURCE
33
+ );
34
+ }
10
35
 
11
36
  export default {
12
37
  meta: {
@@ -23,21 +48,70 @@ export default {
23
48
  'KUI-L101: `{{name}}` is private or uncataloged Kerf UI anatomy. Use a cataloged public class, prop, token, or component instead.',
24
49
  token:
25
50
  'KUI-L102: `{{name}}` is not a cataloged public Kerf UI token. Use a public semantic/component token.',
51
+ component:
52
+ "KUI-L103: `{{name}}` is {{component}}'s rendered anatomy, not a class to place on your own element. Render {{render}} and configure it through its props; components own their styles.",
53
+ componentRoot:
54
+ 'KUI-L103: a plain `<{{element}}>` carrying `{{name}}` is exactly what {{component}} renders. Render {{render}} instead; the class stays placeable only on another carrier element (such as a `<ul>` or a `<footer>`).',
26
55
  },
27
56
  },
28
57
  create(context) {
29
58
  const contract = loadUiContract(context);
30
59
  const filename = context.filename ?? context.getFilename();
31
60
  let configured = false;
32
- const inspect = (node, value, classContext = false) => {
61
+ let registry;
62
+ // A cataloged component's anatomy class on an element the application
63
+ // writes recreates the component by class instead of rendering it. The
64
+ // component's own element (`<Toolbar className="kui-toolbar">`) is exempt.
65
+ // A placeable class whose entry names a `rootElement` is reported only on
66
+ // a plain element of that tag (`<div class="kui-content-item">` is
67
+ // ContentItem); another carrier element keeps it.
68
+ // `element` is the JSX opening element (or `{ tag }` for markup a
69
+ // `kerfjs/html` template writes) that carries the class.
70
+ const inspectPlacement = (node, names, element) => {
71
+ if (isExcepted(contract, PLACEMENT_CODE, filename)) return;
72
+ const opening = element?.type === 'JSXOpeningElement';
73
+ const renderedKey =
74
+ registry && opening
75
+ ? jsxKey(element.name, registry, contract)
76
+ : undefined;
77
+ const intrinsicTag = opening
78
+ ? element.name.type === 'JSXIdentifier'
79
+ ? element.name.name
80
+ : undefined
81
+ : element?.tag;
82
+ for (const name of names) {
83
+ const owner = contract.componentClasses?.get(name);
84
+ if (!owner || owner.key === renderedKey) continue;
85
+ if (owner.element && owner.element !== intrinsicTag) continue;
86
+ context.report({
87
+ node,
88
+ messageId: owner.element ? 'componentRoot' : 'component',
89
+ data: {
90
+ name,
91
+ element: owner.element,
92
+ component: owner.render.join(' / '),
93
+ render: owner.render.map((item) => `\`${item}\``).join(' or '),
94
+ },
95
+ });
96
+ }
97
+ };
98
+ const inspect = (
99
+ node,
100
+ value,
101
+ classContext = false,
102
+ tokens = true,
103
+ element = node.parent,
104
+ ) => {
33
105
  if (typeof value !== 'string') return;
106
+ const names = classContext ? value.split(/\s+/).filter(Boolean) : [];
34
107
  if (classContext && !isExcepted(contract, CLASS_CODE, filename))
35
- for (const name of value
36
- .split(/\s+/)
37
- .filter((item) => item.startsWith('kui-')))
38
- if (!contract.publicClasses.has(name))
108
+ for (const name of names)
109
+ if (name.startsWith('kui-') && !contract.publicClasses.has(name))
39
110
  context.report({ node, messageId: 'class', data: { name } });
40
- if (!isExcepted(contract, TOKEN_CODE, filename))
111
+ // Placement covers every cataloged class, not only `kui-*`: a declared
112
+ // component package's anatomy (`acme-meter`) is checked the same way.
113
+ if (classContext) inspectPlacement(node, names, element);
114
+ if (tokens && !isExcepted(contract, TOKEN_CODE, filename))
41
115
  for (const match of value.matchAll(/--kui-[a-z0-9-]+/g))
42
116
  if (!contract.publicTokens.has(match[0]))
43
117
  context.report({
@@ -56,6 +130,8 @@ export default {
56
130
  data: { error: contract.error },
57
131
  });
58
132
  }
133
+ if (!contract.error)
134
+ registry = importRegistry(node, contract, filename);
59
135
  },
60
136
  JSXAttribute(node) {
61
137
  if (contract.error || !['class', 'className'].includes(node.name?.name))
@@ -67,6 +143,43 @@ export default {
67
143
  node.value.expression.type === 'Literal'
68
144
  )
69
145
  inspect(node, node.value.expression.value, true);
146
+ // A template literal's static, whitespace-delimited class names
147
+ // (`class={`kui-content-item ${extra}`}`); a name an interpolation
148
+ // completes (`kui-toolbar-${size}`) is not known and is skipped.
149
+ if (
150
+ node.value?.type === 'JSXExpressionContainer' &&
151
+ node.value.expression.type === 'TemplateLiteral'
152
+ )
153
+ inspect(
154
+ node,
155
+ staticClassNames(
156
+ node.value.expression.quasis
157
+ .map((quasi) => quasi.value.cooked ?? quasi.value.raw)
158
+ .join('\0'),
159
+ ).join(' '),
160
+ true,
161
+ // Its tokens are inspected once, by TemplateElement below.
162
+ false,
163
+ );
164
+ },
165
+ // `kerfjs/html` tagged templates write the same markup without JSX:
166
+ // their static `class` attributes are inspected like JSX `class`
167
+ // values, on the plain element that carries them. Tokens are left to
168
+ // TemplateElement below.
169
+ TaggedTemplateExpression(node) {
170
+ if (contract.error || !registry || !isHtmlTag(node.tag, registry))
171
+ return;
172
+ const { quasis } = node.quasi;
173
+ for (const { quasi, tag, value } of htmlTemplateClassAttributes(
174
+ quasis.map((item) => item.value.cooked ?? item.value.raw),
175
+ ))
176
+ inspect(
177
+ quasis[quasi],
178
+ staticClassNames(value).join(' '),
179
+ true,
180
+ false,
181
+ { tag },
182
+ );
70
183
  },
71
184
  Literal(node) {
72
185
  if (!contract.error) inspect(node, node.value, false);
@@ -125,6 +125,24 @@ function discoveredProfiles(cwd, filename, settings) {
125
125
  return layers;
126
126
  }
127
127
 
128
+ // `@kerfjs/ui`'s class-to-component rule ships beside its profile contract.
129
+ // An older `@kerfjs/ui` without it cannot report KUI-L103 consistently, so it
130
+ // fails the contract load instead of guessing.
131
+ function loadClassOwnership(profileContractPath) {
132
+ const path = resolve(
133
+ dirname(profileContractPath),
134
+ 'component-class-owners.cjs',
135
+ );
136
+ if (!existsSync(path))
137
+ throw new Error(
138
+ `Cannot find ${path}; install an @kerfjs/ui release that matches eslint-plugin-kerfjs.`,
139
+ );
140
+ const { componentClassOwnership } = loadCommonJs(path);
141
+ if (typeof componentClassOwnership !== 'function')
142
+ throw new Error(`${path} does not export componentClassOwnership.`);
143
+ return componentClassOwnership;
144
+ }
145
+
128
146
  export function loadUiContract(context) {
129
147
  const settings = context.settings?.kerfjs?.ui ?? {};
130
148
  const cwd = settings.workspaceRoot ?? context.cwd ?? process.cwd();
@@ -135,7 +153,7 @@ export function loadUiContract(context) {
135
153
  const catalog =
136
154
  settings.catalog ??
137
155
  readJson(
138
- settings.catalogPath ?? packageAsset(cwd, 'component-catalog-v2.json'),
156
+ settings.catalogPath ?? packageAsset(cwd, 'component-composition.json'),
139
157
  );
140
158
  const selection =
141
159
  settings.selectionCatalog ??
@@ -190,6 +208,20 @@ export function loadUiContract(context) {
190
208
  // `<absolute source file>\0<Export>` to the entry key.
191
209
  const packageExports = new Map();
192
210
  const sourceExports = new Map();
211
+ // Source files of declared components: a wrapper's own source may render
212
+ // the element it wraps (Kerf's PopupMenu renders `wa-dropdown`).
213
+ const componentSources = new Set();
214
+ // Every cataloged entry whose public classes may be a component's rendered
215
+ // anatomy: the base catalog's (exports from the selection catalog) and each
216
+ // declared application/third-party catalog's (exports on the entry).
217
+ const classOwnerEntries = catalog.entries.map((entry) => ({
218
+ key: entry.key,
219
+ name: entry.name,
220
+ boundaries: entry.boundaries,
221
+ exports: (
222
+ selection.entries.find(({ id }) => id === entry.id)?.publicExports ?? []
223
+ ).map(exportName),
224
+ }));
193
225
  for (const { artifact, root } of profileCatalogs(
194
226
  loadedProfile,
195
227
  selection.package,
@@ -197,6 +229,12 @@ export function loadUiContract(context) {
197
229
  for (const entry of artifact.entries ?? []) {
198
230
  const key = entry.key ?? `${artifact.package}:${entry.id}`;
199
231
  entries.set(key, entry);
232
+ classOwnerEntries.push({
233
+ key,
234
+ name: entry.name,
235
+ boundaries: entry.boundaries,
236
+ exports: (entry.publicExports ?? []).map(exportName),
237
+ });
200
238
  for (const item of entry.publicExports ?? []) {
201
239
  const name = exportName(item);
202
240
  if (!name || !isComponentExport(name)) continue;
@@ -210,12 +248,17 @@ export function loadUiContract(context) {
210
248
  : (entry.package ?? artifact.package);
211
249
  packageExports.set(`${specifier}\0${name}`, key);
212
250
  }
213
- if (entry.source)
251
+ if (entry.source) {
214
252
  sourceExports.set(`${resolve(root, entry.source)}\0${name}`, key);
253
+ componentSources.add(resolve(root, entry.source));
254
+ }
215
255
  }
216
256
  }
217
257
  const imports = new Map();
218
258
  const exports = new Map();
259
+ // Custom-element tag -> entry key, for elements authored directly in JSX
260
+ // (`<wa-dropdown>`) rather than through an imported component.
261
+ const customElements = new Map();
219
262
  const helperSources = new Map();
220
263
  const addHelperSource = (name, source) => {
221
264
  const sources = helperSources.get(name) ?? new Set();
@@ -224,6 +267,7 @@ export function loadUiContract(context) {
224
267
  };
225
268
  for (const entry of selection.entries) {
226
269
  const key = `${selection.package}:${entry.id}`;
270
+ if (entry.customElement) customElements.set(entry.customElement, key);
227
271
  if (entry.delivery?.browserImport)
228
272
  imports.set(entry.delivery.browserImport, key);
229
273
  if (entry.delivery?.moduleImport)
@@ -242,12 +286,18 @@ export function loadUiContract(context) {
242
286
  exports,
243
287
  packageExports,
244
288
  sourceExports,
289
+ componentSources,
290
+ customElements,
245
291
  helperSources,
246
292
  profile,
247
293
  cwd,
248
294
  publicClasses: new Set(
249
295
  catalog.entries.flatMap((entry) => entry.boundaries.publicClasses),
250
296
  ),
297
+ componentClasses: componentClassOwners(
298
+ classOwnerEntries,
299
+ loadClassOwnership(profileContractPath),
300
+ ),
251
301
  publicTokens: new Set(
252
302
  catalog.entries.flatMap((entry) => entry.boundaries.publicTokens),
253
303
  ),
@@ -262,6 +312,32 @@ export function loadUiContract(context) {
262
312
  }
263
313
  }
264
314
 
315
+ // Public classes that are a component's rendered anatomy — `@kerfjs/ui`'s
316
+ // `kui-*` classes and any class a declared application or third-party catalog
317
+ // lists (`acme-meter`) — mapped to the entry and the export that renders it.
318
+ // Which classes an entry owns, and which export renders each, is
319
+ // `@kerfjs/ui`'s shared rule (`ai/component-class-owners.cjs`), the same one
320
+ // its generated component reference pages use. The first catalog to claim a
321
+ // class owns it, so the base catalog wins over a declared one.
322
+ function componentClassOwners(classOwnerEntries, componentClassOwnership) {
323
+ const owners = new Map();
324
+ for (const entry of classOwnerEntries)
325
+ for (const { className, render, element } of componentClassOwnership({
326
+ name: entry.name,
327
+ exports: entry.exports,
328
+ publicClasses: entry.boundaries?.publicClasses,
329
+ placeableClasses: entry.boundaries?.placeableClasses,
330
+ rootElement: entry.boundaries?.rootElement,
331
+ }))
332
+ if (!owners.has(className))
333
+ owners.set(className, {
334
+ key: entry.key,
335
+ render,
336
+ ...(element ? { element } : {}),
337
+ });
338
+ return owners;
339
+ }
340
+
265
341
  export function importRegistry(program, contract, filename) {
266
342
  const locals = new Map();
267
343
  const namespaces = new Map();
@@ -405,6 +481,14 @@ export function jsxKey(name, registry, contract) {
405
481
  return undefined;
406
482
  }
407
483
 
484
+ // A custom element authored directly as a lowercase JSX tag resolves to its
485
+ // selection-catalog entry. Kept separate from `jsxKey` so composition rules
486
+ // keep treating raw elements as application-owned markup.
487
+ export function customElementKey(name, contract) {
488
+ if (name.type !== 'JSXIdentifier' || !name.name.includes('-')) return;
489
+ return contract.customElements?.get(name.name);
490
+ }
491
+
408
492
  export function helperCall(callee, registry, contract) {
409
493
  let imported;
410
494
  let source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "5.0.0-beta.56",
3
+ "version": "5.0.0-beta.58",
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",