eslint-plugin-kerfjs 5.0.0-beta.53 → 5.0.0-beta.55

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.
@@ -11,7 +11,19 @@ resolved `.kerf-ui-profile.json` declares under `catalogs`. Their components
11
11
  resolve by package subpath (`import { GroupWrap } from '@acme/bits/group-wrap'`,
12
12
  matched to the entry's `publicExports`) or, for an application's own relative
13
13
  imports, by source file (matched to the entry's `source`, trying the TypeScript
14
- ESM extensions a `.js` specifier stands for).
14
+ ESM extensions a `.js` specifier stands for). An export without a `subpath` (a
15
+ private application's, which is never imported by package name) resolves only
16
+ by source file.
17
+
18
+ A relative import may also reach the source file through relative re-exports,
19
+ the way apps import wrappers through a components barrel:
20
+ `import { DemandSegmentsControl } from './components/index.js'` resolves when
21
+ `index.ts` has `export { DemandSegmentsControl } from '../demand-segments-control.js'`,
22
+ a renamed `export { X as Y } from …` (import `Y`), or `export * from …`, and
23
+ through chains of them (a directory import tries its `index` file). Type-only
24
+ and namespace (`export * as ns`) re-exports, a component defined in the barrel
25
+ itself, and re-exports of anything the catalogs do not declare stay unresolved,
26
+ and cycles stop without resolving.
15
27
 
16
28
  An entry that declares `rendersAs` (a wrapper that renders one of the listed
17
29
  cataloged roots, or nothing) is checked as those roots:
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.53' },
16
+ meta: { name: 'eslint-plugin-kerfjs', version: '5.0.0-beta.55' },
17
17
  rules: {
18
18
  'no-inline-jsx-event-handlers': noInlineJsxEventHandlers,
19
19
  'no-raw-with-dynamic-arg': noRawWithDynamicArg,
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { dirname, extname, relative, resolve } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import process from 'node:process';
@@ -201,11 +201,15 @@ export function loadUiContract(context) {
201
201
  const name = exportName(item);
202
202
  if (!name || !isComponentExport(name)) continue;
203
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);
204
+ // A private application's export has no subpath: it is never
205
+ // imported by package name, only through its source file below.
206
+ if (typeof subpath === 'string') {
207
+ const specifier =
208
+ subpath !== '.'
209
+ ? `${entry.package ?? artifact.package}/${subpath.replace(/^\.\//, '')}`
210
+ : (entry.package ?? artifact.package);
211
+ packageExports.set(`${specifier}\0${name}`, key);
212
+ }
209
213
  if (entry.source)
210
214
  sourceExports.set(`${resolve(root, entry.source)}\0${name}`, key);
211
215
  }
@@ -296,13 +300,95 @@ function declaredComponentKey(contract, source, imported, filename) {
296
300
  if (byPackage) return byPackage;
297
301
  if (!filename || !source.startsWith('.') || !contract.sourceExports?.size)
298
302
  return undefined;
299
- for (const candidate of relativeImportCandidates(filename, source)) {
303
+ return sourceComponentKey(contract, filename, source, imported, new Set());
304
+ }
305
+
306
+ // A relative import names a cataloged component by its defining source file,
307
+ // or through relative re-exports (`export { X } from './x.js'`, `export * from
308
+ // './x.js'`, chains of either) that reach one: apps commonly import wrappers
309
+ // through a components barrel. Anything else — a bare re-export, a local
310
+ // definition, a file that cannot be read — stays unresolved.
311
+ const MAX_REEXPORT_VISITS = 64;
312
+ function sourceComponentKey(contract, fromFile, specifier, imported, seen) {
313
+ const candidates = relativeImportCandidates(fromFile, specifier);
314
+ for (const candidate of candidates) {
300
315
  const key = contract.sourceExports.get(`${candidate}\0${imported}`);
301
316
  if (key) return key;
302
317
  }
318
+ // `seen` holds each (file, name) already followed: it breaks re-export
319
+ // cycles and bounds the work a pathological barrel graph can cost.
320
+ if (seen.size >= MAX_REEXPORT_VISITS) return undefined;
321
+ for (const candidate of candidates) {
322
+ const reExports = relativeReExports(candidate);
323
+ if (!reExports) continue;
324
+ const visit = `${candidate}\0${imported}`;
325
+ if (seen.has(visit)) return undefined;
326
+ seen.add(visit);
327
+ for (const { name, local, from } of reExports) {
328
+ if (name !== undefined && name !== imported) continue;
329
+ const key = sourceComponentKey(
330
+ contract,
331
+ candidate,
332
+ from,
333
+ name === undefined ? imported : local,
334
+ seen,
335
+ );
336
+ if (key) return key;
337
+ }
338
+ // The first file that exists is the one the import resolves to.
339
+ return undefined;
340
+ }
303
341
  return undefined;
304
342
  }
305
343
 
344
+ // The relative value re-exports of one module file, read from its source text:
345
+ // `{ name, local, from }` for `export { local as name } from`, and
346
+ // `{ from }` (every name) for `export * from`. Type-only re-exports and
347
+ // namespace re-exports (`export * as ns`) are skipped. Cached by modification
348
+ // time, so a long-lived editor session sees barrel edits. Undefined when the
349
+ // file does not exist.
350
+ const reExportCache = new Map();
351
+ function relativeReExports(file) {
352
+ let mtime;
353
+ try {
354
+ const stats = statSync(file);
355
+ if (!stats.isFile()) return undefined;
356
+ mtime = stats.mtimeMs;
357
+ } catch {
358
+ return undefined;
359
+ }
360
+ const cached = reExportCache.get(file);
361
+ if (cached?.mtime === mtime) return cached.reExports;
362
+ let text;
363
+ try {
364
+ text = readFileSync(file, 'utf8');
365
+ } catch {
366
+ return undefined;
367
+ }
368
+ const code = text
369
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
370
+ .replace(/(^|[^:\\])\/\/[^\n]*/g, '$1');
371
+ const reExports = [];
372
+ const named = /\bexport\s+(type\s+)?\{([^}]*)\}\s*from\s*(['"])([^'"]+)\3/g;
373
+ for (const match of code.matchAll(named)) {
374
+ const [, typeOnly, list, , from] = match;
375
+ if (typeOnly || !from.startsWith('.')) continue;
376
+ for (const raw of list.split(',')) {
377
+ const part = raw.trim();
378
+ if (!part || /^type\s/.test(part)) continue;
379
+ const [local, name = local] = part.split(/\s+as\s+/).map((x) => x.trim());
380
+ reExports.push({ name, local, from });
381
+ }
382
+ }
383
+ const star = /\bexport\s+\*\s+from\s*(['"])([^'"]+)\1/g;
384
+ for (const match of code.matchAll(star)) {
385
+ const from = match[2];
386
+ if (from.startsWith('.')) reExports.push({ from });
387
+ }
388
+ reExportCache.set(file, { mtime, reExports });
389
+ return reExports;
390
+ }
391
+
306
392
  export function jsxKey(name, registry, contract) {
307
393
  if (name.type === 'JSXIdentifier') return registry.locals.get(name.name);
308
394
  if (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-kerfjs",
3
- "version": "5.0.0-beta.53",
3
+ "version": "5.0.0-beta.55",
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",