eslint-plugin-kerfjs 5.0.0-beta.51 → 5.0.0-beta.53

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.
@@ -2,4 +2,34 @@
2
2
 
3
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
4
 
5
- The parent check is intentionally conservative: it reports only when both child and direct parent resolve to cataloged imports. 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.
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
+
7
+ ## Application and third-party wrappers
8
+
9
+ Beyond `@kerfjs/ui`'s own catalog, the rule loads every composition catalog the
10
+ resolved `.kerf-ui-profile.json` declares under `catalogs`. Their components
11
+ resolve by package subpath (`import { GroupWrap } from '@acme/bits/group-wrap'`,
12
+ matched to the entry's `publicExports`) or, for an application's own relative
13
+ imports, by source file (matched to the entry's `source`, trying the TypeScript
14
+ ESM extensions a `.js` specifier stands for).
15
+
16
+ An entry that declares `rendersAs` (a wrapper that renders one of the listed
17
+ cataloged roots, or nothing) is checked as those roots:
18
+
19
+ - **Zones (`KUI-L202`):** the zone must accept every declared root.
20
+ - **Parents (`KUI-L201`):** each root's listed parents apply where the wrapper
21
+ is placed, and a wrapper used as a parent counts as each of its roots.
22
+ - **Cardinality (`KUI-L203`):** the wrapper counts as zero or one child, since
23
+ it may render nothing.
24
+
25
+ ```tsx
26
+ import { Toolbar } from '@kerfjs/ui';
27
+ import { DemandSegmentsControl } from './demand-segments-control.js';
28
+
29
+ // Valid when the app's catalog declares
30
+ // rendersAs: ["@kerfjs/ui:toolbar-control-group"].
31
+ <Toolbar trailing={<DemandSegmentsControl />} />;
32
+ ```
33
+
34
+ A wrapper without `rendersAs`, or one the profile's catalogs do not declare,
35
+ is still an unknown element and reports `KUI-L202` in a cataloged zone.
package/index.js CHANGED
@@ -13,7 +13,7 @@ import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
13
13
  import uiWiring from './lib/rules/ui-wiring.js';
14
14
 
15
15
  const plugin = {
16
- meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.51' },
16
+ meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.53' },
17
17
  rules: {
18
18
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
19
19
  'no-raw-with-dynamic-arg': noRawWithDynamicArg,
@@ -35,6 +35,14 @@ function zoneShape(node, registry, contract) {
35
35
  if (!node || node.type === 'JSXEmptyExpression') return emptyShape();
36
36
  if (node.type === 'JSXElement') {
37
37
  const key = jsxKey(node.openingElement.name, registry, contract);
38
+ const roots = contract.entries.get(key)?.rendersAs;
39
+ // A declared wrapper renders one of its roots, or nothing.
40
+ if (roots?.length)
41
+ return {
42
+ min: 0,
43
+ max: 1,
44
+ items: [{ key, roots, label: `${key} (renders ${roots.join(' | ')})` }],
45
+ };
38
46
  return {
39
47
  min: 1,
40
48
  max: 1,
@@ -94,6 +102,11 @@ function directParentKey(node, registry, contract) {
94
102
  : undefined;
95
103
  }
96
104
 
105
+ // The cataloged roots an element stands for: a declared wrapper's `rendersAs`,
106
+ // otherwise the element's own entry.
107
+ const rootsOf = (key, contract) =>
108
+ contract.entries.get(key)?.rendersAs ?? [key];
109
+
97
110
  function boundZone(openingElement, zone, registry, contract) {
98
111
  const prop = zone.jsx?.prop;
99
112
  if (!prop) return undefined;
@@ -146,24 +159,42 @@ export default {
146
159
  messageId: 'config',
147
160
  data: { error: contract.error },
148
161
  });
149
- else registry = importRegistry(node, contract);
162
+ else registry = importRegistry(node, contract, filename);
150
163
  },
151
164
  JSXOpeningElement(node) {
152
165
  if (!registry) return;
153
166
  const key = jsxKey(node.name, registry, contract);
154
167
  const entry = contract.entries.get(key);
155
168
  if (!entry) return;
156
- if (
157
- entry.parents.mode === 'listed' &&
158
- !isExcepted(contract, PARENT_CODE, filename)
159
- ) {
169
+ // A wrapper answers to its own parent contract and to each root's.
170
+ const placed = [
171
+ { entry, child: key },
172
+ ...(entry.rendersAs ?? []).flatMap((root) => {
173
+ const rootEntry = contract.entries.get(root);
174
+ return rootEntry
175
+ ? [{ entry: rootEntry, child: `${key} (renders ${root})` }]
176
+ : [];
177
+ }),
178
+ ].filter(
179
+ ({ entry: placedEntry }) => placedEntry.parents?.mode === 'listed',
180
+ );
181
+ if (placed.length && !isExcepted(contract, PARENT_CODE, filename)) {
160
182
  const parent = directParentKey(node, registry, contract);
161
- if (parent && !entry.parents.entries.includes(parent.key))
162
- context.report({
163
- node: node.name,
164
- messageId: 'parent',
165
- data: { child: key, parents: entry.parents.entries.join(', ') },
166
- });
183
+ if (parent)
184
+ for (const { entry: placedEntry, child } of placed)
185
+ if (
186
+ !rootsOf(parent.key, contract).every((root) =>
187
+ placedEntry.parents.entries.includes(root),
188
+ )
189
+ )
190
+ context.report({
191
+ node: node.name,
192
+ messageId: 'parent',
193
+ data: {
194
+ child,
195
+ parents: placedEntry.parents.entries.join(', '),
196
+ },
197
+ });
167
198
  }
168
199
  for (const zone of entry.zones ?? []) {
169
200
  const bound = boundZone(node, zone, registry, contract);
@@ -180,7 +211,11 @@ export default {
180
211
  );
181
212
  if (allowed.size && !isExcepted(contract, ZONE_CODE, filename))
182
213
  for (const child of shape.items)
183
- if (!child.key || !allowed.has(child.key))
214
+ if (
215
+ child.roots
216
+ ? !child.roots.every((root) => allowed.has(root))
217
+ : !child.key || !allowed.has(child.key)
218
+ )
184
219
  context.report({
185
220
  node: reportNode,
186
221
  messageId: 'zone',
@@ -206,7 +206,7 @@ export default {
206
206
  messageId: 'config',
207
207
  data: { error: loaded.error },
208
208
  });
209
- else registry = importRegistry(node, loaded);
209
+ else registry = importRegistry(node, loaded, filename);
210
210
  },
211
211
  JSXOpeningElement(node) {
212
212
  if (!registry) return;
@@ -35,7 +35,7 @@ export default {
35
35
  messageId: 'config',
36
36
  data: { error: contract.error },
37
37
  });
38
- else registry = importRegistry(node, contract);
38
+ else registry = importRegistry(node, contract, filename);
39
39
  },
40
40
  JSXOpeningElement(node) {
41
41
  if (!registry || isExcepted(contract, CODE, filename)) return;
@@ -43,7 +43,7 @@ export default {
43
43
  messageId: 'config',
44
44
  data: { error: contract.error },
45
45
  });
46
- else registry = importRegistry(node, contract);
46
+ else registry = importRegistry(node, contract, filename);
47
47
  },
48
48
  JSXOpeningElement(node) {
49
49
  if (!registry) return;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, relative, resolve } from 'node:path';
2
+ import { dirname, extname, relative, resolve } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import process from 'node:process';
5
5
 
@@ -25,6 +25,57 @@ export function isComponentExport(name) {
25
25
 
26
26
  const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
27
27
 
28
+ // The directory whose package.json names `packageName`, searched upward from a
29
+ // catalog file; a catalog's `source` paths are relative to that package root.
30
+ function packageRootOf(start, packageName) {
31
+ for (let directory = dirname(start); ; directory = dirname(directory)) {
32
+ const manifest = resolve(directory, 'package.json');
33
+ if (existsSync(manifest)) {
34
+ try {
35
+ if (readJson(manifest).name === packageName) return directory;
36
+ } catch {
37
+ // An unreadable manifest is not the owner; keep searching.
38
+ }
39
+ }
40
+ if (dirname(directory) === directory) return dirname(start);
41
+ }
42
+ }
43
+
44
+ const exportName = (item) => (typeof item === 'string' ? item : item?.name);
45
+
46
+ // Application and third-party composition catalogs a profile declares, beyond
47
+ // the @kerfjs/ui catalog the rules load directly. Their entries join the
48
+ // contract, and their components resolve by package subpath (a bare import)
49
+ // or by source file (an app's relative import).
50
+ function profileCatalogs(loadedProfile, basePackage) {
51
+ const catalogs = [];
52
+ for (const catalog of loadedProfile.profile?.catalogs ?? []) {
53
+ if (catalog.package === basePackage) continue;
54
+ const owner = loadedProfile.provenance?.[`$catalogs.${catalog.package}`];
55
+ if (!owner || !catalog.composition?.path || owner.startsWith('<')) continue;
56
+ const path = resolve(dirname(owner), catalog.composition.path);
57
+ const artifact = readJson(path);
58
+ catalogs.push({ artifact, root: packageRootOf(path, catalog.package) });
59
+ }
60
+ return catalogs;
61
+ }
62
+
63
+ // The files a relative import specifier may name, in the order TypeScript's
64
+ // ESM resolution tries them (a `.js` specifier also names its `.ts`/`.tsx`).
65
+ const SCRIPT_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mts', '.mjs'];
66
+ export function relativeImportCandidates(fromFile, specifier) {
67
+ const base = resolve(dirname(fromFile), specifier);
68
+ const extension = extname(base);
69
+ const stem = SCRIPT_EXTENSIONS.includes(extension)
70
+ ? base.slice(0, -extension.length)
71
+ : base;
72
+ return [
73
+ base,
74
+ ...SCRIPT_EXTENSIONS.map((candidate) => `${stem}${candidate}`),
75
+ ...SCRIPT_EXTENSIONS.map((candidate) => resolve(base, `index${candidate}`)),
76
+ ];
77
+ }
78
+
28
79
  function packageAsset(cwd, name) {
29
80
  try {
30
81
  return createRequire(resolve(cwd, 'package.json')).resolve(
@@ -134,6 +185,31 @@ export function loadUiContract(context) {
134
185
  }
135
186
  const profile = loadedProfile.profile;
136
187
  const entries = new Map(catalog.entries.map((entry) => [entry.key, entry]));
188
+ // Declared application/third-party components: `packageExports` maps
189
+ // `<package or package/subpath>\0<Export>` and `sourceExports` maps
190
+ // `<absolute source file>\0<Export>` to the entry key.
191
+ const packageExports = new Map();
192
+ const sourceExports = new Map();
193
+ for (const { artifact, root } of profileCatalogs(
194
+ loadedProfile,
195
+ selection.package,
196
+ ))
197
+ for (const entry of artifact.entries ?? []) {
198
+ const key = entry.key ?? `${artifact.package}:${entry.id}`;
199
+ entries.set(key, entry);
200
+ for (const item of entry.publicExports ?? []) {
201
+ const name = exportName(item);
202
+ if (!name || !isComponentExport(name)) continue;
203
+ const subpath = typeof item === 'string' ? '.' : item.subpath;
204
+ const specifier =
205
+ subpath && subpath !== '.'
206
+ ? `${entry.package ?? artifact.package}/${subpath.replace(/^\.\//, '')}`
207
+ : (entry.package ?? artifact.package);
208
+ packageExports.set(`${specifier}\0${name}`, key);
209
+ if (entry.source)
210
+ sourceExports.set(`${resolve(root, entry.source)}\0${name}`, key);
211
+ }
212
+ }
137
213
  const imports = new Map();
138
214
  const exports = new Map();
139
215
  const helperSources = new Map();
@@ -160,6 +236,8 @@ export function loadUiContract(context) {
160
236
  package: selection.package,
161
237
  imports,
162
238
  exports,
239
+ packageExports,
240
+ sourceExports,
163
241
  helperSources,
164
242
  profile,
165
243
  cwd,
@@ -180,7 +258,7 @@ export function loadUiContract(context) {
180
258
  }
181
259
  }
182
260
 
183
- export function importRegistry(program, contract) {
261
+ export function importRegistry(program, contract, filename) {
184
262
  const locals = new Map();
185
263
  const namespaces = new Map();
186
264
  const helpers = new Map();
@@ -204,7 +282,7 @@ export function importRegistry(program, contract) {
204
282
  : (directKey ??
205
283
  (source === contract.package
206
284
  ? contract.exports?.get(imported)
207
- : undefined));
285
+ : declaredComponentKey(contract, source, imported, filename)));
208
286
  if (key) locals.set(specifier.local.name, key);
209
287
  helpers.set(specifier.local.name, { imported, source });
210
288
  }
@@ -212,6 +290,19 @@ export function importRegistry(program, contract) {
212
290
  return { locals, namespaces, helpers, sources };
213
291
  }
214
292
 
293
+ // An application or third-party component declared in a profile catalog.
294
+ function declaredComponentKey(contract, source, imported, filename) {
295
+ const byPackage = contract.packageExports?.get(`${source}\0${imported}`);
296
+ if (byPackage) return byPackage;
297
+ if (!filename || !source.startsWith('.') || !contract.sourceExports?.size)
298
+ return undefined;
299
+ for (const candidate of relativeImportCandidates(filename, source)) {
300
+ const key = contract.sourceExports.get(`${candidate}\0${imported}`);
301
+ if (key) return key;
302
+ }
303
+ return undefined;
304
+ }
305
+
215
306
  export function jsxKey(name, registry, contract) {
216
307
  if (name.type === 'JSXIdentifier') return registry.locals.get(name.name);
217
308
  if (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "5.0.0-beta.51",
3
+ "version": "5.0.0-beta.53",
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",