eslint-plugin-kerfjs 5.0.0-beta.18 → 5.0.0-beta.19

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
@@ -19,6 +19,7 @@ All but one rule are AST-only — no `@typescript-eslint/parser` _service_ depen
19
19
 
20
20
  ```bash
21
21
  npm install --save-dev eslint-plugin-kerfjs
22
+ npm install @kerfjs/ui
22
23
  ```
23
24
 
24
25
  ### Supported ESLint versions
@@ -48,6 +49,36 @@ export default [
48
49
  ];
49
50
  ```
50
51
 
52
+ Applications using the UI catalog can select one of two additive flat configs:
53
+
54
+ ```js
55
+ export default [
56
+ // parser/files configuration as above
57
+ kerfjs.configs['recommended-ui'], // preferences and wiring are warnings
58
+ // or kerfjs.configs['strict-ui'] // all four UI contracts are errors
59
+ ];
60
+ ```
61
+
62
+ The UI rules resolve the installed `@kerfjs/ui` v1 selection catalog, v2 composition catalog, and application profile defaults. They then merge `.kerf-ui-profile.json` files from the workspace root toward the linted file. Package authors and monorepos can provide explicit artifacts without duplicating their facts:
63
+
64
+ ```js
65
+ settings: {
66
+ kerfjs: {
67
+ ui: {
68
+ workspaceRoot: import.meta.dirname,
69
+ catalogPath: './vendor/component-catalog-v2.json',
70
+ selectionCatalogPath: './vendor/component-catalog.json',
71
+ profileDefaultsPath: './vendor/application-ui-profile.defaults.json',
72
+ profileContractPath: './vendor/application-ui-profile-sync.cjs',
73
+ },
74
+ },
75
+ }
76
+ ```
77
+
78
+ For deterministic generated configs, the same settings accept parsed `catalog`, `selectionCatalog`, and `profile` objects. File-based precedence is package defaults, then workspace profile, then root-to-leaf directory profiles. The rules load `application-ui-profile-sync.cjs` from the installed `@kerfjs/ui` package by default because ESLint rule creation is synchronous; `profileContractPath` is only needed when artifacts are vendored. Every raw layer is schema- and catalog-reference-validated before merge, so a child cannot hide malformed or stale parent policy. Exceptions use exact `KUI-L*` diagnostic ids and repository-relative file or directory targets.
79
+
80
+ `KUI-L090` is reserved for catalog/profile loading and configuration failures; it does not collide with the static analyzer's `KUI-L001`–`KUI-L009` findings. The supported-ESLint matrix installs real packed `eslint-plugin-kerfjs` and `@kerfjs/ui` tarballs in a downstream flat-config fixture, ensuring these defaults resolve from the consumer rather than this repository's source tree.
81
+
51
82
  ## Legacy `.eslintrc` configs are not supported
52
83
 
53
84
  The package is ESM-only. ESLint's `.eslintrc` system loads plugins with `require()`, which cannot load an ESM package, so `extends: ["plugin:kerfjs/legacy-recommended"]` fails with _"couldn't find the config to extend from"_ on ESLint 8 **and** on ESLint 9's legacy mode.
@@ -66,6 +97,10 @@ Use flat config (`eslint.config.js`), shown above. The `legacy-recommended` expo
66
97
  | [`prefer-attr-selector`](docs/rules/prefer-attr-selector.md) | — (rename-safety nudge for `delegate()` selectors) | `warn` |
67
98
  | [`no-raw-with-dynamic-arg`](docs/rules/no-raw-with-dynamic-arg.md) | — (XSS audit trail) | `warn` |
68
99
  | [`ai-assistant-configs`](docs/rules/ai-assistant-configs.md) | — (project hygiene) | `warn` |
100
+ | [`ui-public-boundaries`](docs/rules/ui-public-boundaries.md) | — (cataloged CSS boundaries) | `error` |
101
+ | [`ui-composition`](docs/rules/ui-composition.md) | — (cataloged parents and zones) | `error` |
102
+ | [`ui-preferences`](docs/rules/ui-preferences.md) | — (application component choices) | `warn` |
103
+ | [`ui-wiring`](docs/rules/ui-wiring.md) | — (required setup and cleanup) | `warn` |
69
104
 
70
105
  The "Hard Rule" column refers to the numbered rules in [`docs/ai/usage-guide.md`](../docs/ai/usage-guide.md) on the main kerf repo. `no-raw-with-dynamic-arg` and `ai-assistant-configs` don't map to numbered Hard Rules — the former creates an audit trail for every dynamic `raw()` call site (potential XSS); the latter checks that the bundled AI-assistant configs are installed and current. See [`docs/12-ai-assistant-configs.md`](../docs/12-ai-assistant-configs.md) on the main kerf repo for the AI-configs design.
71
106
 
@@ -0,0 +1,5 @@
1
+ # `ui-composition`
2
+
3
+ Enforces composition facts published by the versioned UI catalog. `KUI-L201` reports a catalog component under a known, disallowed direct parent. `KUI-L202` reports a statically visible zone child—including an intrinsic or unknown JSX element—that is not one of the zone's cataloged `accepts` entries. `KUI-L203` reports a statically provable zone cardinality violation. Toolbar's `leading`, `center`, and `trailing` validation is therefore derived from its catalog entry rather than a duplicated rule-local allowlist.
4
+
5
+ The parent check is intentionally conservative: it reports only when both child and direct parent resolve to cataloged imports. The Toolbar zone check understands fragments, arrays, and conditional/logical branches, but expressions whose content cannot be resolved statically remain runtime and accessibility work. Other catalog zone ids are not assumed to be JSX prop names. No autofix is offered because wrapping or moving UI changes structure and behavior.
@@ -0,0 +1,5 @@
1
+ # `ui-preferences`
2
+
3
+ Reports cataloged components listed in an application UI profile preference's `avoid` set and points to the package-qualified `preferred` component. The stable diagnostic is `KUI-L301`.
4
+
5
+ `recommended-ui` treats the rule as a warning; `strict-ui` treats it as an error. Use a narrowly targeted `KUI-L301` profile exception when a documented application boundary cannot follow the preference. Component substitutions are not autofixed because their props and interaction contracts can differ.
@@ -0,0 +1,7 @@
1
+ # `ui-public-boundaries`
2
+
3
+ Rejects `kui-*` classes and `--kui-*` custom properties that are not listed in the versioned composition catalog. This keeps application code out of private component anatomy while allowing cataloged styling seams.
4
+
5
+ Diagnostics are stable: `KUI-L101` identifies a private/unknown class and `KUI-L102` an unknown token. Exact profile exceptions can name either id. The rule deliberately does not rewrite selectors or tokens because there is no generally safe replacement.
6
+
7
+ The rule reads `@kerfjs/ui/ai/component-catalog-v2.json` by default. See the plugin README for alternate catalog settings used by monorepos and package authors.
@@ -0,0 +1,5 @@
1
+ # `ui-wiring`
2
+
3
+ Checks wiring obligations published by the versioned composition catalog. `KUI-L401` reports a used component whose required helper or side-effect registration import is absent. `KUI-L402` reports a helper call whose disposer is discarded.
4
+
5
+ Assign, return, or otherwise retain a disposer when the binding has a shorter lifetime than the page. A `void` call is accepted as an explicit page-lifetime choice. The rule derives valid helper sources from each v1 entry's `publicExports` and `wiring[].import` facts, so aliases, root named imports, root namespaces, wiring-subpath named imports, and wiring-subpath namespaces resolve without a second hard-coded export table. It does not attempt flow-sensitive cleanup proof and offers no autofix.
package/index.js CHANGED
@@ -6,9 +6,13 @@ import preferAttrSelector from './lib/rules/prefer-attr-selector.js';
6
6
  import preferModuleJsxAugmentation from './lib/rules/prefer-module-jsx-augmentation.js';
7
7
  import requireDataKeyInEach from './lib/rules/require-data-key-in-each.js';
8
8
  import requireDelegateDisposer from './lib/rules/require-delegate-disposer.js';
9
+ import uiComposition from './lib/rules/ui-composition.js';
10
+ import uiPreferences from './lib/rules/ui-preferences.js';
11
+ import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
12
+ import uiWiring from './lib/rules/ui-wiring.js';
9
13
 
10
14
  const plugin = {
11
- meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.18' },
15
+ meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.19' },
12
16
  rules: {
13
17
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
14
18
  'no-raw-with-dynamic-arg': noRawWithDynamicArg,
@@ -18,6 +22,10 @@ const plugin = {
18
22
  'prefer-module-jsx-augmentation': preferModuleJsxAugmentation,
19
23
  'prefer-attr-selector': preferAttrSelector,
20
24
  'ai-assistant-configs': aiAssistantConfigs,
25
+ 'ui-composition': uiComposition,
26
+ 'ui-preferences': uiPreferences,
27
+ 'ui-public-boundaries': uiPublicBoundaries,
28
+ 'ui-wiring': uiWiring,
21
29
  },
22
30
  configs: {},
23
31
  };
@@ -50,6 +58,25 @@ plugin.configs.recommended = {
50
58
  plugins: { kerfjs: plugin },
51
59
  rules: recommendedRules,
52
60
  };
61
+ const recommendedUiRules = {
62
+ ...recommendedRules,
63
+ 'kerfjs/ui-public-boundaries': 'error',
64
+ 'kerfjs/ui-composition': 'error',
65
+ 'kerfjs/ui-preferences': 'warn',
66
+ 'kerfjs/ui-wiring': 'warn',
67
+ };
68
+ plugin.configs['recommended-ui'] = {
69
+ plugins: { kerfjs: plugin },
70
+ rules: recommendedUiRules,
71
+ };
72
+ plugin.configs['strict-ui'] = {
73
+ plugins: { kerfjs: plugin },
74
+ rules: {
75
+ ...recommendedUiRules,
76
+ 'kerfjs/ui-preferences': 'error',
77
+ 'kerfjs/ui-wiring': 'error',
78
+ },
79
+ };
53
80
  plugin.configs.all = plugin.configs.recommended;
54
81
 
55
82
  // Legacy `.eslintrc` config — consumers extend `'plugin:kerfjs/legacy-recommended'`.
@@ -0,0 +1,198 @@
1
+ import {
2
+ importRegistry,
3
+ isExcepted,
4
+ jsxKey,
5
+ loadUiContract,
6
+ UI_CONTRACT_LOAD_CODE,
7
+ UI_RULE_SCHEMA,
8
+ } from '../ui-contract.js';
9
+
10
+ const PARENT_CODE = 'KUI-L201';
11
+ const ZONE_CODE = 'KUI-L202';
12
+ const CARDINALITY_CODE = 'KUI-L203';
13
+
14
+ const emptyShape = () => ({ min: 0, max: 0, items: [] });
15
+
16
+ function combine(shapes) {
17
+ return shapes.reduce(
18
+ (result, shape) => ({
19
+ min: result.min + shape.min,
20
+ max: result.max + shape.max,
21
+ items: [...result.items, ...shape.items],
22
+ }),
23
+ emptyShape(),
24
+ );
25
+ }
26
+
27
+ function jsxLabel(name) {
28
+ if (name.type === 'JSXIdentifier') return `<${name.name}>`;
29
+ if (name.type === 'JSXMemberExpression')
30
+ return `${jsxLabel(name.object).slice(0, -1)}.${name.property.name}>`;
31
+ return '<unknown>';
32
+ }
33
+
34
+ function zoneShape(node, registry, contract) {
35
+ if (!node || node.type === 'JSXEmptyExpression') return emptyShape();
36
+ if (node.type === 'JSXElement') {
37
+ const key = jsxKey(node.openingElement.name, registry, contract);
38
+ return {
39
+ min: 1,
40
+ max: 1,
41
+ items: [{ key, label: key ?? jsxLabel(node.openingElement.name) }],
42
+ };
43
+ }
44
+ if (node.type === 'JSXFragment')
45
+ return combine(
46
+ node.children.map((child) => zoneShape(child, registry, contract)),
47
+ );
48
+ if (node.type === 'JSXText')
49
+ return node.value.trim()
50
+ ? { min: 1, max: 1, items: [{ label: 'text' }] }
51
+ : emptyShape();
52
+ if (node.type === 'JSXExpressionContainer')
53
+ return zoneShape(node.expression, registry, contract);
54
+ if (node.type === 'ArrayExpression')
55
+ return combine(
56
+ node.elements.map((child) => zoneShape(child, registry, contract)),
57
+ );
58
+ if (node.type === 'ConditionalExpression') {
59
+ const consequent = zoneShape(node.consequent, registry, contract);
60
+ const alternate = zoneShape(node.alternate, registry, contract);
61
+ return {
62
+ min: Math.min(consequent.min, alternate.min),
63
+ max: Math.max(consequent.max, alternate.max),
64
+ items: [...consequent.items, ...alternate.items],
65
+ };
66
+ }
67
+ if (node.type === 'LogicalExpression') {
68
+ const left = zoneShape(node.left, registry, contract);
69
+ const right = zoneShape(node.right, registry, contract);
70
+ return node.operator === '&&'
71
+ ? { min: 0, max: right.max, items: right.items }
72
+ : {
73
+ min: Math.min(left.min, right.min),
74
+ max: Math.max(left.max, right.max),
75
+ items: [...left.items, ...right.items],
76
+ };
77
+ }
78
+ if (node.type === 'Literal')
79
+ return node.value === null || typeof node.value === 'boolean'
80
+ ? emptyShape()
81
+ : { min: 1, max: 1, items: [{ label: 'text' }] };
82
+ return { min: 0, max: Number.POSITIVE_INFINITY, items: [] };
83
+ }
84
+
85
+ function directParentKey(node, registry, contract) {
86
+ let parent = node.parent?.parent;
87
+ while (
88
+ parent &&
89
+ ['JSXExpressionContainer', 'JSXFragment'].includes(parent.type)
90
+ )
91
+ parent = parent.parent;
92
+ return parent?.type === 'JSXElement'
93
+ ? { key: jsxKey(parent.openingElement.name, registry, contract) }
94
+ : undefined;
95
+ }
96
+
97
+ export default {
98
+ meta: {
99
+ type: 'problem',
100
+ docs: {
101
+ description:
102
+ 'Enforce low-false-positive catalog parent and zone contracts.',
103
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-composition.md',
104
+ },
105
+ schema: UI_RULE_SCHEMA,
106
+ messages: {
107
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
108
+ parent:
109
+ 'KUI-L201: `{{child}}` requires one of these cataloged parents: {{parents}}.',
110
+ zone: 'KUI-L202: `{{component}}` zone `{{zone}}` does not accept {{child}}; catalog accepts: {{accepts}}.',
111
+ cardinality:
112
+ 'KUI-L203: `{{component}}` zone `{{zone}}` requires {{cardinality}} direct children; statically found {{count}}.',
113
+ },
114
+ },
115
+ create(context) {
116
+ const contract = loadUiContract(context);
117
+ const filename = context.filename ?? context.getFilename();
118
+ let registry;
119
+ return {
120
+ Program(node) {
121
+ if (contract.error)
122
+ context.report({
123
+ node,
124
+ messageId: 'config',
125
+ data: { error: contract.error },
126
+ });
127
+ else registry = importRegistry(node, contract);
128
+ },
129
+ JSXOpeningElement(node) {
130
+ if (!registry) return;
131
+ const key = jsxKey(node.name, registry, contract);
132
+ const entry = contract.entries.get(key);
133
+ if (!entry) return;
134
+ if (
135
+ entry.parents.mode === 'listed' &&
136
+ !isExcepted(contract, PARENT_CODE, filename)
137
+ ) {
138
+ const parent = directParentKey(node, registry, contract);
139
+ if (parent && !entry.parents.entries.includes(parent.key))
140
+ context.report({
141
+ node: node.name,
142
+ messageId: 'parent',
143
+ data: { child: key, parents: entry.parents.entries.join(', ') },
144
+ });
145
+ }
146
+ // Catalog zone ids are not universally JSX prop names. Toolbar's public
147
+ // API deliberately exposes its three zones as same-named props, so only
148
+ // that component has a sound static prop-to-zone mapping here.
149
+ if (entry.id !== 'toolbar') return;
150
+ for (const zone of entry.zones ?? []) {
151
+ const attribute = node.attributes.find(
152
+ (candidate) =>
153
+ candidate.type === 'JSXAttribute' &&
154
+ candidate.name.name === zone.id,
155
+ );
156
+ const shape = zoneShape(attribute?.value, registry, contract);
157
+ const allowed = new Set(
158
+ zone.accepts
159
+ .map((accepted) =>
160
+ accepted.includes(':')
161
+ ? accepted
162
+ : `${entry.package}:${accepted}`,
163
+ )
164
+ .filter((accepted) => contract.entries.has(accepted)),
165
+ );
166
+ if (allowed.size && !isExcepted(contract, ZONE_CODE, filename))
167
+ for (const child of shape.items)
168
+ if (!child.key || !allowed.has(child.key))
169
+ context.report({
170
+ node: attribute ?? node.name,
171
+ messageId: 'zone',
172
+ data: {
173
+ component: key,
174
+ zone: zone.id,
175
+ child: child.label,
176
+ accepts: [...allowed].join(', '),
177
+ },
178
+ });
179
+ if (isExcepted(contract, CARDINALITY_CODE, filename)) continue;
180
+ const { min, max } = zone.cardinality;
181
+ const tooFew = shape.max < min;
182
+ const tooMany = max !== 'unbounded' && shape.min > max;
183
+ if (tooFew || tooMany)
184
+ context.report({
185
+ node: attribute ?? node.name,
186
+ messageId: 'cardinality',
187
+ data: {
188
+ component: key,
189
+ zone: zone.id,
190
+ cardinality: `${min}..${max}`,
191
+ count: tooFew ? shape.max : shape.min,
192
+ },
193
+ });
194
+ }
195
+ },
196
+ };
197
+ },
198
+ };
@@ -0,0 +1,63 @@
1
+ import {
2
+ importRegistry,
3
+ isExcepted,
4
+ jsxKey,
5
+ loadUiContract,
6
+ UI_CONTRACT_LOAD_CODE,
7
+ UI_RULE_SCHEMA,
8
+ } from '../ui-contract.js';
9
+
10
+ const CODE = 'KUI-L301';
11
+
12
+ export default {
13
+ meta: {
14
+ type: 'suggestion',
15
+ docs: {
16
+ description: 'Apply application-profile preferred UI component choices.',
17
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-preferences.md',
18
+ },
19
+ schema: UI_RULE_SCHEMA,
20
+ messages: {
21
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
22
+ preferred:
23
+ 'KUI-L301: `{{actual}}` is discouraged for `{{concept}}`; this profile prefers `{{preferred}}`. {{rationale}}',
24
+ },
25
+ },
26
+ create(context) {
27
+ const contract = loadUiContract(context);
28
+ const filename = context.filename ?? context.getFilename();
29
+ let registry;
30
+ return {
31
+ Program(node) {
32
+ if (contract.error)
33
+ context.report({
34
+ node,
35
+ messageId: 'config',
36
+ data: { error: contract.error },
37
+ });
38
+ else registry = importRegistry(node, contract);
39
+ },
40
+ JSXOpeningElement(node) {
41
+ if (!registry || isExcepted(contract, CODE, filename)) return;
42
+ const actual = jsxKey(node.name, registry, contract);
43
+ if (!actual) return;
44
+ for (const [concept, preference] of Object.entries(
45
+ contract.profile.preferences ?? {},
46
+ ))
47
+ if ((preference.avoid ?? []).includes(actual))
48
+ context.report({
49
+ node: node.name,
50
+ messageId: 'preferred',
51
+ data: {
52
+ actual,
53
+ concept,
54
+ preferred: preference.preferred,
55
+ rationale:
56
+ preference.rationale ??
57
+ 'Use the project preference unless an exact profile exception applies.',
58
+ },
59
+ });
60
+ },
61
+ };
62
+ },
63
+ };
@@ -0,0 +1,79 @@
1
+ import {
2
+ isExcepted,
3
+ loadUiContract,
4
+ UI_CONTRACT_LOAD_CODE,
5
+ UI_RULE_SCHEMA,
6
+ } from '../ui-contract.js';
7
+
8
+ const CLASS_CODE = 'KUI-L101';
9
+ const TOKEN_CODE = 'KUI-L102';
10
+
11
+ export default {
12
+ meta: {
13
+ type: 'problem',
14
+ docs: {
15
+ description:
16
+ 'Restrict Kerf UI classes and tokens to cataloged public boundaries.',
17
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-public-boundaries.md',
18
+ },
19
+ schema: UI_RULE_SCHEMA,
20
+ messages: {
21
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
22
+ class:
23
+ 'KUI-L101: `{{name}}` is private or uncataloged Kerf UI anatomy. Use a cataloged public class, prop, token, or component instead.',
24
+ token:
25
+ 'KUI-L102: `{{name}}` is not a cataloged public Kerf UI token. Use a public semantic/component token.',
26
+ },
27
+ },
28
+ create(context) {
29
+ const contract = loadUiContract(context);
30
+ const filename = context.filename ?? context.getFilename();
31
+ let configured = false;
32
+ const inspect = (node, value, classContext = false) => {
33
+ if (typeof value !== 'string') return;
34
+ 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))
39
+ context.report({ node, messageId: 'class', data: { name } });
40
+ if (!isExcepted(contract, TOKEN_CODE, filename))
41
+ for (const match of value.matchAll(/--kui-[a-z0-9-]+/g))
42
+ if (!contract.publicTokens.has(match[0]))
43
+ context.report({
44
+ node,
45
+ messageId: 'token',
46
+ data: { name: match[0] },
47
+ });
48
+ };
49
+ return {
50
+ Program(node) {
51
+ if (contract.error && !configured) {
52
+ configured = true;
53
+ context.report({
54
+ node,
55
+ messageId: 'config',
56
+ data: { error: contract.error },
57
+ });
58
+ }
59
+ },
60
+ JSXAttribute(node) {
61
+ if (contract.error || !['class', 'className'].includes(node.name?.name))
62
+ return;
63
+ if (node.value?.type === 'Literal')
64
+ inspect(node, node.value.value, true);
65
+ if (
66
+ node.value?.type === 'JSXExpressionContainer' &&
67
+ node.value.expression.type === 'Literal'
68
+ )
69
+ inspect(node, node.value.expression.value, true);
70
+ },
71
+ Literal(node) {
72
+ if (!contract.error) inspect(node, node.value, false);
73
+ },
74
+ TemplateElement(node) {
75
+ if (!contract.error) inspect(node, node.value.raw, false);
76
+ },
77
+ };
78
+ },
79
+ };
@@ -0,0 +1,102 @@
1
+ import {
2
+ helperCall,
3
+ importRegistry,
4
+ isExcepted,
5
+ jsxKey,
6
+ loadUiContract,
7
+ UI_RULE_SCHEMA,
8
+ UI_CONTRACT_LOAD_CODE,
9
+ } from '../ui-contract.js';
10
+
11
+ const MISSING_CODE = 'KUI-L401';
12
+ const CLEANUP_CODE = 'KUI-L402';
13
+
14
+ const retained = (call) => call.parent?.type !== 'ExpressionStatement';
15
+
16
+ export default {
17
+ meta: {
18
+ type: 'problem',
19
+ docs: {
20
+ description: 'Require cataloged Kerf UI wiring and retained cleanup.',
21
+ url: 'https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/docs/rules/ui-wiring.md',
22
+ },
23
+ schema: UI_RULE_SCHEMA,
24
+ messages: {
25
+ config: `${UI_CONTRACT_LOAD_CODE}: Kerf UI catalog/profile could not be loaded: {{error}}`,
26
+ missing:
27
+ 'KUI-L401: `{{component}}` requires `{{helper}}`; import and invoke the documented wiring contract.',
28
+ cleanup:
29
+ 'KUI-L402: Retain or return the disposer from `{{helper}}`; use `void` only for an intentional page-lifetime binding.',
30
+ },
31
+ },
32
+ create(context) {
33
+ const contract = loadUiContract(context);
34
+ const filename = context.filename ?? context.getFilename();
35
+ let registry;
36
+ const used = new Map();
37
+ const calls = new Map();
38
+ return {
39
+ Program(node) {
40
+ if (contract.error)
41
+ context.report({
42
+ node,
43
+ messageId: 'config',
44
+ data: { error: contract.error },
45
+ });
46
+ else registry = importRegistry(node, contract);
47
+ },
48
+ JSXOpeningElement(node) {
49
+ if (!registry) return;
50
+ const key = jsxKey(node.name, registry, contract);
51
+ if (key && !used.has(key)) used.set(key, node.name);
52
+ },
53
+ CallExpression(node) {
54
+ if (!registry) return;
55
+ const imported = helperCall(node.callee, registry, contract);
56
+ if (!imported) return;
57
+ const list = calls.get(imported.imported) ?? [];
58
+ list.push({ node, source: imported.source });
59
+ calls.set(imported.imported, list);
60
+ },
61
+ 'Program:exit'(node) {
62
+ if (!registry) return;
63
+ for (const [key, usage] of used) {
64
+ const entry = contract.entries.get(key);
65
+ if (!entry?.wiring.required) continue;
66
+ for (const helper of entry.wiring.helpers) {
67
+ if (helper.startsWith('@')) {
68
+ if (
69
+ !registry.sources.has(helper) &&
70
+ !isExcepted(contract, MISSING_CODE, filename)
71
+ )
72
+ context.report({
73
+ node: usage,
74
+ messageId: 'missing',
75
+ data: { component: key, helper },
76
+ });
77
+ continue;
78
+ }
79
+ const helperCalls = calls.get(helper) ?? [];
80
+ if (
81
+ !helperCalls.length &&
82
+ !isExcepted(contract, MISSING_CODE, filename)
83
+ )
84
+ context.report({
85
+ node: usage,
86
+ messageId: 'missing',
87
+ data: { component: key, helper },
88
+ });
89
+ if (!isExcepted(contract, CLEANUP_CODE, filename))
90
+ for (const { node: call } of helperCalls)
91
+ if (!retained(call))
92
+ context.report({
93
+ node: call,
94
+ messageId: 'cleanup',
95
+ data: { helper },
96
+ });
97
+ }
98
+ }
99
+ },
100
+ };
101
+ },
102
+ };
@@ -0,0 +1,249 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, relative, resolve } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import process from 'node:process';
5
+
6
+ const PROFILE = '.kerf-ui-profile.json';
7
+ const cache = new Map();
8
+ const loadCommonJs = createRequire(import.meta.url);
9
+
10
+ export const UI_CONTRACT_LOAD_CODE = 'KUI-L090';
11
+
12
+ const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
13
+
14
+ function packageAsset(cwd, name) {
15
+ try {
16
+ return createRequire(resolve(cwd, 'package.json')).resolve(
17
+ `@kerfjs/ui/ai/${name}`,
18
+ );
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ function discoveredProfiles(cwd, filename, settings) {
25
+ if (settings.profile)
26
+ return [
27
+ {
28
+ source: '<eslint settings kerfjs.ui.profile>',
29
+ profile: settings.profile,
30
+ },
31
+ ];
32
+ const layers = [];
33
+ const add = (path, expectedScope) => {
34
+ const profile = readJson(path);
35
+ if (profile.scope !== expectedScope)
36
+ throw new Error(
37
+ `${path} must have profile scope ${expectedScope}, received ${profile.scope ?? 'missing'}`,
38
+ );
39
+ layers.push({ source: path, profile });
40
+ };
41
+ const defaultsPath =
42
+ settings.profileDefaultsPath ??
43
+ packageAsset(cwd, 'application-ui-profile.defaults.json');
44
+ if (defaultsPath && existsSync(defaultsPath)) add(defaultsPath, 'package');
45
+ const workspace = resolve(settings.workspaceRoot ?? cwd);
46
+ const target = resolve(dirname(filename));
47
+ if (relative(workspace, target).startsWith('..')) return layers;
48
+ const directories = [];
49
+ let directory = target;
50
+ while (true) {
51
+ directories.push(directory);
52
+ if (directory === workspace) break;
53
+ directory = dirname(directory);
54
+ }
55
+ for (const candidate of directories.reverse()) {
56
+ const path = resolve(candidate, PROFILE);
57
+ if (existsSync(path))
58
+ add(path, candidate === workspace ? 'workspace' : 'directory');
59
+ }
60
+ return layers;
61
+ }
62
+
63
+ export function loadUiContract(context) {
64
+ const settings = context.settings?.kerfjs?.ui ?? {};
65
+ const cwd = settings.workspaceRoot ?? context.cwd ?? process.cwd();
66
+ const filename = context.filename ?? context.getFilename();
67
+ const key = JSON.stringify([cwd, filename, settings]);
68
+ if (cache.has(key)) return cache.get(key);
69
+ try {
70
+ const catalog =
71
+ settings.catalog ??
72
+ readJson(
73
+ settings.catalogPath ?? packageAsset(cwd, 'component-catalog-v2.json'),
74
+ );
75
+ const selection =
76
+ settings.selectionCatalog ??
77
+ readJson(
78
+ settings.selectionCatalogPath ??
79
+ packageAsset(cwd, 'component-catalog.json'),
80
+ );
81
+ const profiles = settings.profilePath
82
+ ? [
83
+ {
84
+ source: settings.profilePath,
85
+ profile: readJson(settings.profilePath),
86
+ },
87
+ ]
88
+ : discoveredProfiles(cwd, filename, settings);
89
+ const profileContractPath =
90
+ settings.profileContractPath ??
91
+ packageAsset(cwd, 'application-ui-profile-sync.cjs');
92
+ if (!profileContractPath)
93
+ throw new Error(
94
+ 'Cannot resolve @kerfjs/ui/ai/application-ui-profile-sync.cjs; install @kerfjs/ui or set kerfjs.ui.profileContractPath.',
95
+ );
96
+ const { loadApplicationUiProfileSync } = loadCommonJs(profileContractPath);
97
+ const diagnosticRegistryPath =
98
+ settings.diagnosticRegistryPath ??
99
+ resolve(
100
+ dirname(profileContractPath),
101
+ 'application-ui-diagnostic-ids-v1.json',
102
+ );
103
+ const diagnosticRegistry = readJson(diagnosticRegistryPath);
104
+ if (
105
+ diagnosticRegistry.schemaVersion !== 1 ||
106
+ !Array.isArray(diagnosticRegistry.ids)
107
+ )
108
+ throw new Error(
109
+ `${diagnosticRegistryPath} must be an application UI diagnostic registry with schemaVersion 1.`,
110
+ );
111
+ const loadedProfile = loadApplicationUiProfileSync(profiles, {
112
+ fallbackCatalogs: [catalog],
113
+ knownRules: diagnosticRegistry.ids,
114
+ });
115
+ if (loadedProfile.diagnostics.length) {
116
+ const diagnostic = loadedProfile.diagnostics[0];
117
+ throw new Error(
118
+ `${diagnostic.source} ${diagnostic.path} ${diagnostic.code}: ${diagnostic.message}`,
119
+ );
120
+ }
121
+ const profile = loadedProfile.profile;
122
+ const entries = new Map(catalog.entries.map((entry) => [entry.key, entry]));
123
+ const imports = new Map();
124
+ const exports = new Map();
125
+ const helperSources = new Map();
126
+ const addHelperSource = (name, source) => {
127
+ const sources = helperSources.get(name) ?? new Set();
128
+ sources.add(source);
129
+ helperSources.set(name, sources);
130
+ };
131
+ for (const entry of selection.entries) {
132
+ const key = `${selection.package}:${entry.id}`;
133
+ if (entry.delivery?.browserImport)
134
+ imports.set(entry.delivery.browserImport, key);
135
+ for (const name of entry.publicExports ?? []) {
136
+ exports.set(name, key);
137
+ addHelperSource(name, selection.package);
138
+ }
139
+ for (const wiring of entry.wiring ?? [])
140
+ addHelperSource(wiring.export, wiring.import);
141
+ }
142
+ const value = {
143
+ entries,
144
+ package: selection.package,
145
+ imports,
146
+ exports,
147
+ helperSources,
148
+ profile,
149
+ cwd,
150
+ publicClasses: new Set(
151
+ catalog.entries.flatMap((entry) => entry.boundaries.publicClasses),
152
+ ),
153
+ publicTokens: new Set(
154
+ catalog.entries.flatMap((entry) => entry.boundaries.publicTokens),
155
+ ),
156
+ error: undefined,
157
+ };
158
+ cache.set(key, value);
159
+ return value;
160
+ } catch (error) {
161
+ const value = { error: error.message };
162
+ cache.set(key, value);
163
+ return value;
164
+ }
165
+ }
166
+
167
+ export function importRegistry(program, contract) {
168
+ const locals = new Map();
169
+ const namespaces = new Map();
170
+ const helpers = new Map();
171
+ const sources = new Set();
172
+ for (const node of program.body) {
173
+ if (node.type !== 'ImportDeclaration') continue;
174
+ const source = node.source.value;
175
+ sources.add(source);
176
+ const directKey = contract.imports?.get(source);
177
+ for (const specifier of node.specifiers) {
178
+ if (specifier.type === 'ImportNamespaceSpecifier') {
179
+ namespaces.set(specifier.local.name, source);
180
+ continue;
181
+ }
182
+ const imported =
183
+ specifier.type === 'ImportDefaultSpecifier'
184
+ ? 'default'
185
+ : (specifier.imported.name ?? specifier.imported.value);
186
+ const key =
187
+ directKey ??
188
+ (source === contract.package
189
+ ? contract.exports?.get(imported)
190
+ : undefined);
191
+ if (key) locals.set(specifier.local.name, key);
192
+ helpers.set(specifier.local.name, { imported, source });
193
+ }
194
+ }
195
+ return { locals, namespaces, helpers, sources };
196
+ }
197
+
198
+ export function jsxKey(name, registry, contract) {
199
+ if (name.type === 'JSXIdentifier') return registry.locals.get(name.name);
200
+ if (
201
+ name.type === 'JSXMemberExpression' &&
202
+ name.object.type === 'JSXIdentifier' &&
203
+ name.property.type === 'JSXIdentifier' &&
204
+ registry.namespaces.has(name.object.name)
205
+ ) {
206
+ const source = registry.namespaces.get(name.object.name);
207
+ const exportedKey = contract.exports.get(name.property.name);
208
+ if (source === contract.package) return exportedKey;
209
+ if (contract.imports.get(source) === exportedKey) return exportedKey;
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ export function helperCall(callee, registry, contract) {
215
+ let imported;
216
+ let source;
217
+ if (callee.type === 'Identifier') {
218
+ ({ imported, source } = registry.helpers.get(callee.name) ?? {});
219
+ } else if (
220
+ callee.type === 'MemberExpression' &&
221
+ !callee.computed &&
222
+ callee.object.type === 'Identifier' &&
223
+ callee.property.type === 'Identifier'
224
+ ) {
225
+ imported = callee.property.name;
226
+ source = registry.namespaces.get(callee.object.name);
227
+ }
228
+ if (!imported || !source) return undefined;
229
+ return contract.helperSources.get(imported)?.has(source)
230
+ ? { imported, source }
231
+ : undefined;
232
+ }
233
+
234
+ export function isExcepted(contract, code, filename) {
235
+ const projectPath = relative(contract.cwd, filename).replaceAll('\\', '/');
236
+ return (contract.profile?.exceptions ?? []).some((exception) => {
237
+ if (!exception.rules.includes(code)) return false;
238
+ const target = exception.target.replace(/^\.\//, '').replace(/\/$/, '');
239
+ return projectPath === target || projectPath.startsWith(`${target}/`);
240
+ });
241
+ }
242
+
243
+ export const UI_RULE_SCHEMA = [
244
+ {
245
+ type: 'object',
246
+ properties: {},
247
+ additionalProperties: false,
248
+ },
249
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "5.0.0-beta.18",
3
+ "version": "5.0.0-beta.19",
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",