eslint-plugin-kerfjs 5.0.0-beta.68 → 5.0.0-beta.69
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 +6 -0
- package/docs/rules/ui-wiring.md +15 -1
- package/index.js +1 -1
- package/lib/rules/ui-composition.js +4 -1
- package/lib/rules/ui-wiring.js +63 -6
- package/lib/ui-contract.js +44 -7
- package/package.json +1 -1
|
@@ -5,6 +5,12 @@ Enforces composition facts published by the versioned UI catalog. `KUI-L201` rep
|
|
|
5
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
6
|
An additional JSX export from an entry can declare its own `jsxExports.<name>.parents` contract. It uses that placement instead of the entry's root placement and is opaque to zone matching, so an action link does not impersonate its enclosing control group.
|
|
7
7
|
|
|
8
|
+
`KUI-L202` inspects JSX written directly in a bound zone prop, including its
|
|
9
|
+
fragments, arrays, and conditional branches. It does not follow a local binding
|
|
10
|
+
such as `const actions = <div />; <TabBar trailing={actions} />`. Review those
|
|
11
|
+
bindings against the zone's catalog contract; an unknown expression produces
|
|
12
|
+
no zone-child diagnostic.
|
|
13
|
+
|
|
8
14
|
## Application and third-party wrappers
|
|
9
15
|
|
|
10
16
|
Beyond `@kerfjs/ui`'s own catalog, the rule loads every composition catalog the
|
package/docs/rules/ui-wiring.md
CHANGED
|
@@ -8,6 +8,20 @@ Declare application entry modules in the application UI profile to check root wi
|
|
|
8
8
|
{ "schemaVersion": 1, "scope": "workspace", "wiring": { "entries": ["src/main.tsx"] } }
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Entry paths are relative to the workspace root. The rule follows relative imports, re-exports, and literal dynamic imports from each entry. An application-scoped obligation is checked at the entry if any reachable module renders its component; reachable leaf modules need no duplicate wiring. A module outside every declared entry keeps the per-file check. A composition entry can set `wiring.scope` to `"module"` for a helper that must be installed beside its rendering code; omitted scope means `"application"`. Computed import paths and unresolved modules stay outside the static graph, so put them in a declared entry or check them separately. `KUI-L402` still checks the helper's call site.
|
|
11
|
+
Entry paths are relative to the workspace root. The rule follows relative imports, re-exports, and literal dynamic imports from each entry. An application-scoped obligation is checked at the entry if any reachable module renders its component; a recognized helper call in any reachable module satisfies it. Reachable leaf modules need no duplicate wiring. A module outside every declared entry keeps the per-file check. A composition entry can set `wiring.scope` to `"module"` for a helper that must be installed beside its rendering code; omitted scope means `"application"`. Computed import paths and unresolved modules stay outside the static graph, so put them in a declared entry or check them separately. `KUI-L402` still checks the helper's call site.
|
|
12
|
+
|
|
13
|
+
Consumer composition catalogs can name an application helper's defining file in `wiring.sources`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
"wiring": {
|
|
17
|
+
"required": true,
|
|
18
|
+
"helpers": ["wireTicketSearch"],
|
|
19
|
+
"sources": [{ "export": "wireTicketSearch", "source": "src/interactions/ticket-search.ts" }]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The source path is relative to the catalog package root. The rule resolves relative imports, aliases, and re-export barrels to that file, so another function with the same name does not satisfy the obligation.
|
|
24
|
+
|
|
25
|
+
The selection catalog can declare a public wiring provider in `wiringProviders`, naming its export, import source, and the helpers it installs. A recognized `wireCatalog` call from `@kerfjs/ui/wire-catalog` provides `wireScrollDividers`, so the rule does not ask for a duplicate call on the same application entry. The provider's disposer still needs to be retained.
|
|
12
26
|
|
|
13
27
|
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
|
@@ -14,7 +14,7 @@ import uiPublicBoundaries from './lib/rules/ui-public-boundaries.js';
|
|
|
14
14
|
import uiWiring from './lib/rules/ui-wiring.js';
|
|
15
15
|
|
|
16
16
|
const plugin = {
|
|
17
|
-
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.
|
|
17
|
+
meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.69' },
|
|
18
18
|
rules: {
|
|
19
19
|
'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
|
|
20
20
|
'no-raw-with-dynamic-arg': noRawWithDynamicArg,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
customElementKey,
|
|
2
3
|
importRegistry,
|
|
3
4
|
isExcepted,
|
|
4
5
|
jsxExportName,
|
|
@@ -35,7 +36,9 @@ function jsxLabel(name) {
|
|
|
35
36
|
function zoneShape(node, registry, contract) {
|
|
36
37
|
if (!node || node.type === 'JSXEmptyExpression') return emptyShape();
|
|
37
38
|
if (node.type === 'JSXElement') {
|
|
38
|
-
const key =
|
|
39
|
+
const key =
|
|
40
|
+
jsxKey(node.openingElement.name, registry, contract) ??
|
|
41
|
+
customElementKey(node.openingElement.name, contract);
|
|
39
42
|
const entry = contract.entries.get(key);
|
|
40
43
|
const exportName = jsxExportName(node.openingElement.name, registry);
|
|
41
44
|
// A second JSX export from one package entry may have its own element
|
package/lib/rules/ui-wiring.js
CHANGED
|
@@ -103,6 +103,57 @@ function moduleUses(source, file, contract) {
|
|
|
103
103
|
return uses;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// ESLint supplies an AST only for the file currently being linted. For other
|
|
107
|
+
// files in an entry graph, recognize imported helper calls by their local
|
|
108
|
+
// binding (including aliases and namespace imports) and resolved source.
|
|
109
|
+
function moduleCalls(source, file, contract) {
|
|
110
|
+
const calls = new Set();
|
|
111
|
+
// Keep import declarations intact, but exclude comments and literals from
|
|
112
|
+
// the call search so a documented example cannot satisfy an obligation.
|
|
113
|
+
const callableText = source.replace(
|
|
114
|
+
/\/\*[\s\S]*?\*\/|\/\/[^\n]*|(['"`])(?:\\.|(?!\1)[^\\])*?\1/g,
|
|
115
|
+
(match) => match.replace(/[^\n]/g, ' '),
|
|
116
|
+
);
|
|
117
|
+
const declarations = /\bimport\s+([^;'"`]*?)\s+from\s*['"]([^'"]+)['"]/g;
|
|
118
|
+
for (const [, bindings, from] of source.matchAll(declarations)) {
|
|
119
|
+
const named = bindings.match(/\{([^}]*)\}/)?.[1];
|
|
120
|
+
if (named)
|
|
121
|
+
for (const part of named.split(',')) {
|
|
122
|
+
const match = part.trim().match(/^(\w+)(?:\s+as\s+(\w+))?$/);
|
|
123
|
+
if (!match) continue;
|
|
124
|
+
const [, imported, local = imported] = match;
|
|
125
|
+
if (new RegExp(`\\b${local}\\s*\\(`).test(callableText)) {
|
|
126
|
+
const recognized = helperCall(
|
|
127
|
+
{ type: 'Identifier', name: local },
|
|
128
|
+
{ helpers: new Map([[local, { imported, source: from }]]) },
|
|
129
|
+
contract,
|
|
130
|
+
file,
|
|
131
|
+
);
|
|
132
|
+
if (recognized) calls.add(recognized.imported);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const namespace = bindings.match(/\*\s+as\s+(\w+)/)?.[1];
|
|
136
|
+
if (namespace)
|
|
137
|
+
for (const [, helper] of callableText.matchAll(
|
|
138
|
+
new RegExp(`\\b${namespace}\\.(\\w+)\\s*\\(`, 'g'),
|
|
139
|
+
)) {
|
|
140
|
+
const recognized = helperCall(
|
|
141
|
+
{
|
|
142
|
+
type: 'MemberExpression',
|
|
143
|
+
computed: false,
|
|
144
|
+
object: { type: 'Identifier', name: namespace },
|
|
145
|
+
property: { type: 'Identifier', name: helper },
|
|
146
|
+
},
|
|
147
|
+
{ namespaces: new Map([[namespace, from]]) },
|
|
148
|
+
contract,
|
|
149
|
+
file,
|
|
150
|
+
);
|
|
151
|
+
if (recognized) calls.add(recognized.imported);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return calls;
|
|
155
|
+
}
|
|
156
|
+
|
|
106
157
|
function applicationEntries(contract, filename, source) {
|
|
107
158
|
const root = resolve(contract.cwd);
|
|
108
159
|
const entries = [];
|
|
@@ -154,7 +205,7 @@ export default {
|
|
|
154
205
|
},
|
|
155
206
|
CallExpression(node) {
|
|
156
207
|
if (!registry) return;
|
|
157
|
-
const imported = helperCall(node.callee, registry, contract);
|
|
208
|
+
const imported = helperCall(node.callee, registry, contract, filename);
|
|
158
209
|
if (!imported) return;
|
|
159
210
|
const list = calls.get(imported.imported) ?? [];
|
|
160
211
|
list.push({ node, source: imported.source });
|
|
@@ -171,6 +222,12 @@ export default {
|
|
|
171
222
|
modules.has(filename),
|
|
172
223
|
);
|
|
173
224
|
const ownEntry = entries.find(({ file }) => file === filename);
|
|
225
|
+
const reachableCalls = new Set(calls.keys());
|
|
226
|
+
if (ownEntry)
|
|
227
|
+
for (const [file, source] of ownEntry.modules)
|
|
228
|
+
if (file !== filename)
|
|
229
|
+
for (const helper of moduleCalls(source, file, contract))
|
|
230
|
+
reachableCalls.add(helper);
|
|
174
231
|
const required = new Map(used);
|
|
175
232
|
if (ownEntry)
|
|
176
233
|
for (const [file, source] of ownEntry.modules)
|
|
@@ -195,11 +252,11 @@ export default {
|
|
|
195
252
|
});
|
|
196
253
|
continue;
|
|
197
254
|
}
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
)
|
|
255
|
+
const providers = contract.helperProviders.get(helper) ?? new Set();
|
|
256
|
+
const provided = [helper, ...providers].some((name) =>
|
|
257
|
+
ownEntry ? reachableCalls.has(name) : calls.has(name),
|
|
258
|
+
);
|
|
259
|
+
if (!provided && !isExcepted(contract, MISSING_CODE, filename))
|
|
203
260
|
context.report({
|
|
204
261
|
node: usage,
|
|
205
262
|
messageId: 'missing',
|
package/lib/ui-contract.js
CHANGED
|
@@ -208,6 +208,7 @@ export function loadUiContract(context) {
|
|
|
208
208
|
// `<absolute source file>\0<Export>` to the entry key.
|
|
209
209
|
const packageExports = new Map();
|
|
210
210
|
const sourceExports = new Map();
|
|
211
|
+
const helperSourceFiles = new Map();
|
|
211
212
|
// Source files of declared components: a wrapper's own source may render
|
|
212
213
|
// the element it wraps (Kerf's PopupMenu renders `wa-dropdown`).
|
|
213
214
|
const componentSources = new Set();
|
|
@@ -253,6 +254,11 @@ export function loadUiContract(context) {
|
|
|
253
254
|
componentSources.add(resolve(root, entry.source));
|
|
254
255
|
}
|
|
255
256
|
}
|
|
257
|
+
for (const helper of entry.wiring?.sources ?? [])
|
|
258
|
+
helperSourceFiles.set(
|
|
259
|
+
`${resolve(root, helper.source)}\0${helper.export}`,
|
|
260
|
+
helper.export,
|
|
261
|
+
);
|
|
256
262
|
}
|
|
257
263
|
const imports = new Map();
|
|
258
264
|
const exports = new Map();
|
|
@@ -260,6 +266,7 @@ export function loadUiContract(context) {
|
|
|
260
266
|
// (`<wa-dropdown>`) rather than through an imported component.
|
|
261
267
|
const customElements = new Map();
|
|
262
268
|
const helperSources = new Map();
|
|
269
|
+
const helperProviders = new Map();
|
|
263
270
|
const addHelperSource = (name, source) => {
|
|
264
271
|
const sources = helperSources.get(name) ?? new Set();
|
|
265
272
|
sources.add(source);
|
|
@@ -279,6 +286,14 @@ export function loadUiContract(context) {
|
|
|
279
286
|
for (const wiring of entry.wiring ?? [])
|
|
280
287
|
addHelperSource(wiring.export, wiring.import);
|
|
281
288
|
}
|
|
289
|
+
for (const provider of selection.wiringProviders ?? []) {
|
|
290
|
+
addHelperSource(provider.export, provider.import);
|
|
291
|
+
for (const helper of provider.provides) {
|
|
292
|
+
const providers = helperProviders.get(helper) ?? new Set();
|
|
293
|
+
providers.add(provider.export);
|
|
294
|
+
helperProviders.set(helper, providers);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
282
297
|
const value = {
|
|
283
298
|
entries,
|
|
284
299
|
package: selection.package,
|
|
@@ -289,6 +304,8 @@ export function loadUiContract(context) {
|
|
|
289
304
|
componentSources,
|
|
290
305
|
customElements,
|
|
291
306
|
helperSources,
|
|
307
|
+
helperProviders,
|
|
308
|
+
helperSourceFiles,
|
|
292
309
|
profile,
|
|
293
310
|
cwd,
|
|
294
311
|
publicClasses: new Set(
|
|
@@ -386,9 +403,19 @@ function declaredComponentKey(contract, source, imported, filename) {
|
|
|
386
403
|
// definition, a file that cannot be read — stays unresolved.
|
|
387
404
|
const MAX_REEXPORT_VISITS = 64;
|
|
388
405
|
function sourceComponentKey(contract, fromFile, specifier, imported, seen) {
|
|
406
|
+
return sourceExportKey(
|
|
407
|
+
contract.sourceExports,
|
|
408
|
+
fromFile,
|
|
409
|
+
specifier,
|
|
410
|
+
imported,
|
|
411
|
+
seen,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sourceExportKey(exports, fromFile, specifier, imported, seen) {
|
|
389
416
|
const candidates = relativeImportCandidates(fromFile, specifier);
|
|
390
417
|
for (const candidate of candidates) {
|
|
391
|
-
const key =
|
|
418
|
+
const key = exports.get(`${candidate}\0${imported}`);
|
|
392
419
|
if (key) return key;
|
|
393
420
|
}
|
|
394
421
|
// `seen` holds each (file, name) already followed: it breaks re-export
|
|
@@ -402,8 +429,8 @@ function sourceComponentKey(contract, fromFile, specifier, imported, seen) {
|
|
|
402
429
|
seen.add(visit);
|
|
403
430
|
for (const { name, local, from } of reExports) {
|
|
404
431
|
if (name !== undefined && name !== imported) continue;
|
|
405
|
-
const key =
|
|
406
|
-
|
|
432
|
+
const key = sourceExportKey(
|
|
433
|
+
exports,
|
|
407
434
|
candidate,
|
|
408
435
|
from,
|
|
409
436
|
name === undefined ? imported : local,
|
|
@@ -502,7 +529,7 @@ export function customElementKey(name, contract) {
|
|
|
502
529
|
return contract.customElements?.get(name.name);
|
|
503
530
|
}
|
|
504
531
|
|
|
505
|
-
export function helperCall(callee, registry, contract) {
|
|
532
|
+
export function helperCall(callee, registry, contract, filename) {
|
|
506
533
|
let imported;
|
|
507
534
|
let source;
|
|
508
535
|
if (callee.type === 'Identifier') {
|
|
@@ -517,9 +544,19 @@ export function helperCall(callee, registry, contract) {
|
|
|
517
544
|
source = registry.namespaces.get(callee.object.name);
|
|
518
545
|
}
|
|
519
546
|
if (!imported || !source) return undefined;
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
547
|
+
if (contract.helperSources.get(imported)?.has(source))
|
|
548
|
+
return { imported, source };
|
|
549
|
+
if (filename && source.startsWith('.') && contract.helperSourceFiles?.size) {
|
|
550
|
+
const resolved = sourceExportKey(
|
|
551
|
+
contract.helperSourceFiles,
|
|
552
|
+
filename,
|
|
553
|
+
source,
|
|
554
|
+
imported,
|
|
555
|
+
new Set(),
|
|
556
|
+
);
|
|
557
|
+
if (resolved) return { imported: resolved, source };
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
523
560
|
}
|
|
524
561
|
|
|
525
562
|
export function isExcepted(contract, code, filename) {
|