eslint-plugin-kerfjs 5.0.0-beta.67 → 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.
@@ -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
@@ -2,4 +2,26 @@
2
2
 
3
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
4
 
5
+ Declare application entry modules in the application UI profile to check root wiring once per entry:
6
+
7
+ ```json
8
+ { "schemaVersion": 1, "scope": "workspace", "wiring": { "entries": ["src/main.tsx"] } }
9
+ ```
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; 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.
26
+
5
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.67' },
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 = jsxKey(node.openingElement.name, registry, contract);
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
@@ -1,9 +1,13 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { relative, resolve } from 'node:path';
3
+
1
4
  import {
2
5
  helperCall,
3
6
  importRegistry,
4
7
  isExcepted,
5
8
  jsxKey,
6
9
  loadUiContract,
10
+ relativeImportCandidates,
7
11
  UI_RULE_SCHEMA,
8
12
  UI_CONTRACT_LOAD_CODE,
9
13
  } from '../ui-contract.js';
@@ -13,6 +17,155 @@ const CLEANUP_CODE = 'KUI-L402';
13
17
 
14
18
  const retained = (call) => call.parent?.type !== 'ExpressionStatement';
15
19
 
20
+ const script = /\.(?:[cm]?[jt]sx?)$/;
21
+ const imports =
22
+ /(?:^|[;\n])\s*(?:import|export)\s+(?:[^;'"`]*?\s+from\s*)?['"]([^'"]+)['"]/g;
23
+ const dynamicImports = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
24
+
25
+ function entryModules(entryFile, currentFile, currentText) {
26
+ const modules = new Map();
27
+ const visit = (file) => {
28
+ if (modules.has(file)) return;
29
+ let source;
30
+ try {
31
+ source = file === currentFile ? currentText : readFileSync(file, 'utf8');
32
+ } catch {
33
+ return;
34
+ }
35
+ modules.set(file, source);
36
+ for (const [, specifier] of [
37
+ ...source.matchAll(imports),
38
+ ...source.matchAll(dynamicImports),
39
+ ]) {
40
+ if (!specifier.startsWith('.')) continue;
41
+ const dependency = relativeImportCandidates(file, specifier).find(
42
+ (candidate) => script.test(candidate) && existsSync(candidate),
43
+ );
44
+ if (dependency) visit(dependency);
45
+ }
46
+ };
47
+ visit(entryFile);
48
+ return modules;
49
+ }
50
+
51
+ // The entry graph is read from disk because ESLint visits one source file at a
52
+ // time. Match only imported JSX names; an unrelated local tag cannot satisfy a
53
+ // catalog obligation. The current entry's in-memory text covers editor linting.
54
+ function moduleUses(source, file, contract) {
55
+ const uses = new Set();
56
+ const declarations = /\bimport\s+([^;'"`]*?)\s+from\s*['"]([^'"]+)['"]/g;
57
+ for (const [, bindings, from] of source.matchAll(declarations)) {
58
+ const direct = contract.imports.get(from);
59
+ const keyFor = (name) =>
60
+ direct ??
61
+ (from === contract.package ? contract.exports.get(name) : undefined) ??
62
+ contract.packageExports.get(`${from}\0${name}`) ??
63
+ (from.startsWith('.')
64
+ ? relativeImportCandidates(file, from)
65
+ .map((candidate) =>
66
+ contract.sourceExports.get(`${candidate}\0${name}`),
67
+ )
68
+ .find(Boolean)
69
+ : undefined);
70
+ const named = bindings.match(/\{([^}]*)\}/)?.[1];
71
+ if (named)
72
+ for (const part of named.split(',')) {
73
+ const match = part.trim().match(/^(\w+)(?:\s+as\s+(\w+))?$/);
74
+ if (!match) continue;
75
+ const [, imported, local = imported] = match;
76
+ const key = keyFor(imported);
77
+ if (key && new RegExp(`<${local}(?=[\\s/>])`).test(source))
78
+ uses.add(key);
79
+ }
80
+ const namespace = bindings.match(/\*\s+as\s+(\w+)/)?.[1];
81
+ if (namespace) {
82
+ const names = new Set([
83
+ ...contract.exports.keys(),
84
+ ...[...contract.packageExports.keys()].map((key) => key.split('\0')[1]),
85
+ ]);
86
+ for (const name of names) {
87
+ const key = keyFor(name);
88
+ if (
89
+ key &&
90
+ new RegExp(`<${namespace}\\.${name}(?=[\\s/>])`).test(source)
91
+ )
92
+ uses.add(key);
93
+ }
94
+ }
95
+ const defaultName = bindings.match(/^\s*(\w+)\s*(?:,|$)/)?.[1];
96
+ if (
97
+ direct &&
98
+ defaultName &&
99
+ new RegExp(`<${defaultName}(?=[\\s/>])`).test(source)
100
+ )
101
+ uses.add(direct);
102
+ }
103
+ return uses;
104
+ }
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
+
157
+ function applicationEntries(contract, filename, source) {
158
+ const root = resolve(contract.cwd);
159
+ const entries = [];
160
+ for (const path of contract.profile.wiring?.entries ?? []) {
161
+ const file = resolve(root, path);
162
+ if (relative(root, file).startsWith('..')) continue;
163
+ const modules = entryModules(file, filename, source);
164
+ if (modules.size) entries.push({ file, modules });
165
+ }
166
+ return entries;
167
+ }
168
+
16
169
  export default {
17
170
  meta: {
18
171
  type: 'problem',
@@ -52,7 +205,7 @@ export default {
52
205
  },
53
206
  CallExpression(node) {
54
207
  if (!registry) return;
55
- const imported = helperCall(node.callee, registry, contract);
208
+ const imported = helperCall(node.callee, registry, contract, filename);
56
209
  if (!imported) return;
57
210
  const list = calls.get(imported.imported) ?? [];
58
211
  list.push({ node, source: imported.source });
@@ -60,9 +213,32 @@ export default {
60
213
  },
61
214
  'Program:exit'(node) {
62
215
  if (!registry) return;
63
- for (const [key, usage] of used) {
216
+ const entries = applicationEntries(
217
+ contract,
218
+ filename,
219
+ context.sourceCode.text,
220
+ );
221
+ const reachable = entries.filter(({ modules }) =>
222
+ modules.has(filename),
223
+ );
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);
231
+ const required = new Map(used);
232
+ if (ownEntry)
233
+ for (const [file, source] of ownEntry.modules)
234
+ for (const key of moduleUses(source, file, contract))
235
+ if (!required.has(key)) required.set(key, node);
236
+ for (const [key, usage] of required) {
64
237
  const entry = contract.entries.get(key);
65
238
  if (!entry?.wiring.required) continue;
239
+ if (entry.wiring.scope === 'module' && !used.has(key)) continue;
240
+ if (entry.wiring.scope !== 'module' && reachable.length && !ownEntry)
241
+ continue;
66
242
  for (const helper of entry.wiring.helpers) {
67
243
  if (helper.startsWith('@')) {
68
244
  if (
@@ -76,17 +252,27 @@ export default {
76
252
  });
77
253
  continue;
78
254
  }
79
- const helperCalls = calls.get(helper) ?? [];
80
- if (
81
- !helperCalls.length &&
82
- !isExcepted(contract, MISSING_CODE, filename)
83
- )
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))
84
260
  context.report({
85
261
  node: usage,
86
262
  messageId: 'missing',
87
263
  data: { component: key, helper },
88
264
  });
89
- if (!isExcepted(contract, CLEANUP_CODE, filename))
265
+ }
266
+ }
267
+ if (!isExcepted(contract, CLEANUP_CODE, filename)) {
268
+ const disposerHelpers = new Set(
269
+ [...contract.entries.values()]
270
+ .filter((entry) => entry.wiring?.required)
271
+ .flatMap((entry) => entry.wiring.helpers)
272
+ .filter((helper) => !helper.startsWith('@')),
273
+ );
274
+ for (const [helper, helperCalls] of calls)
275
+ if (disposerHelpers.has(helper))
90
276
  for (const { node: call } of helperCalls)
91
277
  if (!retained(call))
92
278
  context.report({
@@ -94,7 +280,6 @@ export default {
94
280
  messageId: 'cleanup',
95
281
  data: { helper },
96
282
  });
97
- }
98
283
  }
99
284
  },
100
285
  };
@@ -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 = contract.sourceExports.get(`${candidate}\0${imported}`);
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 = sourceComponentKey(
406
- contract,
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
- return contract.helperSources.get(imported)?.has(source)
521
- ? { imported, source }
522
- : undefined;
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "5.0.0-beta.67",
3
+ "version": "5.0.0-beta.69",
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",