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.
@@ -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
@@ -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.68' },
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
@@ -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 helperCalls = calls.get(helper) ?? [];
199
- if (
200
- !helperCalls.length &&
201
- !isExcepted(contract, MISSING_CODE, filename)
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',
@@ -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.68",
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",