eslint-plugin-kerfjs 5.0.0-beta.67 → 5.0.0-beta.68

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,12 @@
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; 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
+
5
13
  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.68' },
18
18
  rules: {
19
19
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
20
20
  'no-raw-with-dynamic-arg': noRawWithDynamicArg,
@@ -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,104 @@ 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
+ function applicationEntries(contract, filename, source) {
107
+ const root = resolve(contract.cwd);
108
+ const entries = [];
109
+ for (const path of contract.profile.wiring?.entries ?? []) {
110
+ const file = resolve(root, path);
111
+ if (relative(root, file).startsWith('..')) continue;
112
+ const modules = entryModules(file, filename, source);
113
+ if (modules.size) entries.push({ file, modules });
114
+ }
115
+ return entries;
116
+ }
117
+
16
118
  export default {
17
119
  meta: {
18
120
  type: 'problem',
@@ -60,9 +162,26 @@ export default {
60
162
  },
61
163
  'Program:exit'(node) {
62
164
  if (!registry) return;
63
- for (const [key, usage] of used) {
165
+ const entries = applicationEntries(
166
+ contract,
167
+ filename,
168
+ context.sourceCode.text,
169
+ );
170
+ const reachable = entries.filter(({ modules }) =>
171
+ modules.has(filename),
172
+ );
173
+ const ownEntry = entries.find(({ file }) => file === filename);
174
+ const required = new Map(used);
175
+ if (ownEntry)
176
+ for (const [file, source] of ownEntry.modules)
177
+ for (const key of moduleUses(source, file, contract))
178
+ if (!required.has(key)) required.set(key, node);
179
+ for (const [key, usage] of required) {
64
180
  const entry = contract.entries.get(key);
65
181
  if (!entry?.wiring.required) continue;
182
+ if (entry.wiring.scope === 'module' && !used.has(key)) continue;
183
+ if (entry.wiring.scope !== 'module' && reachable.length && !ownEntry)
184
+ continue;
66
185
  for (const helper of entry.wiring.helpers) {
67
186
  if (helper.startsWith('@')) {
68
187
  if (
@@ -86,7 +205,17 @@ export default {
86
205
  messageId: 'missing',
87
206
  data: { component: key, helper },
88
207
  });
89
- if (!isExcepted(contract, CLEANUP_CODE, filename))
208
+ }
209
+ }
210
+ if (!isExcepted(contract, CLEANUP_CODE, filename)) {
211
+ const disposerHelpers = new Set(
212
+ [...contract.entries.values()]
213
+ .filter((entry) => entry.wiring?.required)
214
+ .flatMap((entry) => entry.wiring.helpers)
215
+ .filter((helper) => !helper.startsWith('@')),
216
+ );
217
+ for (const [helper, helperCalls] of calls)
218
+ if (disposerHelpers.has(helper))
90
219
  for (const { node: call } of helperCalls)
91
220
  if (!retained(call))
92
221
  context.report({
@@ -94,7 +223,6 @@ export default {
94
223
  messageId: 'cleanup',
95
224
  data: { helper },
96
225
  });
97
- }
98
226
  }
99
227
  },
100
228
  };
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.68",
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",