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.
- package/docs/rules/ui-composition.md +31 -1
- package/index.js +1 -1
- package/lib/rules/ui-composition.js +47 -12
- package/lib/rules/ui-css-values.js +1 -1
- package/lib/rules/ui-preferences.js +1 -1
- package/lib/rules/ui-wiring.js +1 -1
- package/lib/ui-contract.js +94 -3
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 (
|
|
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;
|
package/lib/rules/ui-wiring.js
CHANGED
package/lib/ui-contract.js
CHANGED
|
@@ -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
|
-
:
|
|
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 (
|