eslint-plugin-kerfjs 5.0.0-beta.52 → 5.0.0-beta.54

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,46 @@
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). An export without a `subpath` (a
15
+ private application's, which is never imported by package name) resolves only
16
+ by source file.
17
+
18
+ A relative import may also reach the source file through relative re-exports,
19
+ the way apps import wrappers through a components barrel:
20
+ `import { DemandSegmentsControl } from './components/index.js'` resolves when
21
+ `index.ts` has `export { DemandSegmentsControl } from '../demand-segments-control.js'`,
22
+ a renamed `export { X as Y } from …` (import `Y`), or `export * from …`, and
23
+ through chains of them (a directory import tries its `index` file). Type-only
24
+ and namespace (`export * as ns`) re-exports, a component defined in the barrel
25
+ itself, and re-exports of anything the catalogs do not declare stay unresolved,
26
+ and cycles stop without resolving.
27
+
28
+ An entry that declares `rendersAs` (a wrapper that renders one of the listed
29
+ cataloged roots, or nothing) is checked as those roots:
30
+
31
+ - **Zones (`KUI-L202`):** the zone must accept every declared root.
32
+ - **Parents (`KUI-L201`):** each root's listed parents apply where the wrapper
33
+ is placed, and a wrapper used as a parent counts as each of its roots.
34
+ - **Cardinality (`KUI-L203`):** the wrapper counts as zero or one child, since
35
+ it may render nothing.
36
+
37
+ ```tsx
38
+ import { Toolbar } from '@kerfjs/ui';
39
+ import { DemandSegmentsControl } from './demand-segments-control.js';
40
+
41
+ // Valid when the app's catalog declares
42
+ // rendersAs: ["@kerfjs/ui:toolbar-control-group"].
43
+ <Toolbar trailing={<DemandSegmentsControl />} />;
44
+ ```
45
+
46
+ A wrapper without `rendersAs`, or one the profile's catalogs do not declare,
47
+ 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.52' },
16
+ meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.54' },
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
- import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, relative, resolve } from 'node:path';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
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,35 @@ 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
+ // A private application's export has no subpath: it is never
205
+ // imported by package name, only through its source file below.
206
+ if (typeof subpath === 'string') {
207
+ const specifier =
208
+ subpath !== '.'
209
+ ? `${entry.package ?? artifact.package}/${subpath.replace(/^\.\//, '')}`
210
+ : (entry.package ?? artifact.package);
211
+ packageExports.set(`${specifier}\0${name}`, key);
212
+ }
213
+ if (entry.source)
214
+ sourceExports.set(`${resolve(root, entry.source)}\0${name}`, key);
215
+ }
216
+ }
137
217
  const imports = new Map();
138
218
  const exports = new Map();
139
219
  const helperSources = new Map();
@@ -160,6 +240,8 @@ export function loadUiContract(context) {
160
240
  package: selection.package,
161
241
  imports,
162
242
  exports,
243
+ packageExports,
244
+ sourceExports,
163
245
  helperSources,
164
246
  profile,
165
247
  cwd,
@@ -180,7 +262,7 @@ export function loadUiContract(context) {
180
262
  }
181
263
  }
182
264
 
183
- export function importRegistry(program, contract) {
265
+ export function importRegistry(program, contract, filename) {
184
266
  const locals = new Map();
185
267
  const namespaces = new Map();
186
268
  const helpers = new Map();
@@ -204,7 +286,7 @@ export function importRegistry(program, contract) {
204
286
  : (directKey ??
205
287
  (source === contract.package
206
288
  ? contract.exports?.get(imported)
207
- : undefined));
289
+ : declaredComponentKey(contract, source, imported, filename)));
208
290
  if (key) locals.set(specifier.local.name, key);
209
291
  helpers.set(specifier.local.name, { imported, source });
210
292
  }
@@ -212,6 +294,101 @@ export function importRegistry(program, contract) {
212
294
  return { locals, namespaces, helpers, sources };
213
295
  }
214
296
 
297
+ // An application or third-party component declared in a profile catalog.
298
+ function declaredComponentKey(contract, source, imported, filename) {
299
+ const byPackage = contract.packageExports?.get(`${source}\0${imported}`);
300
+ if (byPackage) return byPackage;
301
+ if (!filename || !source.startsWith('.') || !contract.sourceExports?.size)
302
+ return undefined;
303
+ return sourceComponentKey(contract, filename, source, imported, new Set());
304
+ }
305
+
306
+ // A relative import names a cataloged component by its defining source file,
307
+ // or through relative re-exports (`export { X } from './x.js'`, `export * from
308
+ // './x.js'`, chains of either) that reach one: apps commonly import wrappers
309
+ // through a components barrel. Anything else — a bare re-export, a local
310
+ // definition, a file that cannot be read — stays unresolved.
311
+ const MAX_REEXPORT_VISITS = 64;
312
+ function sourceComponentKey(contract, fromFile, specifier, imported, seen) {
313
+ const candidates = relativeImportCandidates(fromFile, specifier);
314
+ for (const candidate of candidates) {
315
+ const key = contract.sourceExports.get(`${candidate}\0${imported}`);
316
+ if (key) return key;
317
+ }
318
+ // `seen` holds each (file, name) already followed: it breaks re-export
319
+ // cycles and bounds the work a pathological barrel graph can cost.
320
+ if (seen.size >= MAX_REEXPORT_VISITS) return undefined;
321
+ for (const candidate of candidates) {
322
+ const reExports = relativeReExports(candidate);
323
+ if (!reExports) continue;
324
+ const visit = `${candidate}\0${imported}`;
325
+ if (seen.has(visit)) return undefined;
326
+ seen.add(visit);
327
+ for (const { name, local, from } of reExports) {
328
+ if (name !== undefined && name !== imported) continue;
329
+ const key = sourceComponentKey(
330
+ contract,
331
+ candidate,
332
+ from,
333
+ name === undefined ? imported : local,
334
+ seen,
335
+ );
336
+ if (key) return key;
337
+ }
338
+ // The first file that exists is the one the import resolves to.
339
+ return undefined;
340
+ }
341
+ return undefined;
342
+ }
343
+
344
+ // The relative value re-exports of one module file, read from its source text:
345
+ // `{ name, local, from }` for `export { local as name } from`, and
346
+ // `{ from }` (every name) for `export * from`. Type-only re-exports and
347
+ // namespace re-exports (`export * as ns`) are skipped. Cached by modification
348
+ // time, so a long-lived editor session sees barrel edits. Undefined when the
349
+ // file does not exist.
350
+ const reExportCache = new Map();
351
+ function relativeReExports(file) {
352
+ let mtime;
353
+ try {
354
+ const stats = statSync(file);
355
+ if (!stats.isFile()) return undefined;
356
+ mtime = stats.mtimeMs;
357
+ } catch {
358
+ return undefined;
359
+ }
360
+ const cached = reExportCache.get(file);
361
+ if (cached?.mtime === mtime) return cached.reExports;
362
+ let text;
363
+ try {
364
+ text = readFileSync(file, 'utf8');
365
+ } catch {
366
+ return undefined;
367
+ }
368
+ const code = text
369
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
370
+ .replace(/(^|[^:\\])\/\/[^\n]*/g, '$1');
371
+ const reExports = [];
372
+ const named = /\bexport\s+(type\s+)?\{([^}]*)\}\s*from\s*(['"])([^'"]+)\3/g;
373
+ for (const match of code.matchAll(named)) {
374
+ const [, typeOnly, list, , from] = match;
375
+ if (typeOnly || !from.startsWith('.')) continue;
376
+ for (const raw of list.split(',')) {
377
+ const part = raw.trim();
378
+ if (!part || /^type\s/.test(part)) continue;
379
+ const [local, name = local] = part.split(/\s+as\s+/).map((x) => x.trim());
380
+ reExports.push({ name, local, from });
381
+ }
382
+ }
383
+ const star = /\bexport\s+\*\s+from\s*(['"])([^'"]+)\1/g;
384
+ for (const match of code.matchAll(star)) {
385
+ const from = match[2];
386
+ if (from.startsWith('.')) reExports.push({ from });
387
+ }
388
+ reExportCache.set(file, { mtime, reExports });
389
+ return reExports;
390
+ }
391
+
215
392
  export function jsxKey(name, registry, contract) {
216
393
  if (name.type === 'JSXIdentifier') return registry.locals.get(name.name);
217
394
  if (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "5.0.0-beta.52",
3
+ "version": "5.0.0-beta.54",
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",