@dxos/eslint-plugin-rules 0.8.4-main.84f28bd → 0.8.4-main.c4373fc

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/index.js CHANGED
@@ -2,10 +2,44 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- module.exports = {
5
+ import comment from './rules/comment.js';
6
+ import effectSubpathImports from './rules/effect-subpath-imports.js';
7
+ import header from './rules/header.js';
8
+ import noEmptyPromiseCatch from './rules/no-empty-promise-catch.js';
9
+ import fs from 'node:fs';
10
+
11
+ const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
12
+
13
+ const plugin = {
14
+ meta: {
15
+ name: pkg.name,
16
+ version: pkg.version,
17
+ namespace: 'example',
18
+ },
6
19
  rules: {
7
- comment: require('./rules/comment'),
8
- header: require('./rules/header'),
9
- 'no-empty-promise-catch': require('./rules/no-empty-promise-catch'),
20
+ comment,
21
+ 'effect-subpath-imports': effectSubpathImports,
22
+ header,
23
+ 'no-empty-promise-catch': noEmptyPromiseCatch,
24
+ },
25
+ configs: {
26
+ recommended: {
27
+ plugins: {
28
+ 'dxos-plugin': null,
29
+ },
30
+ rules: {
31
+ 'dxos-plugin/effect-subpath-imports': 'error',
32
+ 'dxos-plugin/header': 'error',
33
+ 'dxos-plugin/no-empty-promise-catch': 'error',
34
+ // TODO(dmaretskyi): Turned off due to large number of errors and no auto-fix.
35
+ // 'dxos-plugin/comment': 'error',
36
+ },
37
+ },
10
38
  },
11
39
  };
40
+
41
+ Object.assign(plugin.configs.recommended.plugins, {
42
+ 'dxos-plugin': plugin,
43
+ });
44
+
45
+ export default plugin;
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@dxos/eslint-plugin-rules",
3
- "version": "0.8.4-main.84f28bd",
3
+ "version": "0.8.4-main.c4373fc",
4
4
  "homepage": "https://dxos.org",
5
5
  "bugs": "https://github.com/dxos/dxos/issues",
6
6
  "license": "MIT",
7
7
  "author": "info@dxos.org",
8
8
  "sideEffects": true,
9
- "main": "index.js",
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./index.js"
12
+ },
10
13
  "publishConfig": {
11
14
  "access": "public"
12
15
  }
package/rules/comment.js CHANGED
@@ -10,7 +10,8 @@
10
10
  // Requires
11
11
  // ------------------------------------------------------------------------------
12
12
 
13
- const HEADER_PATTERN = require('./header').pattern;
13
+ import header from './header.js';
14
+ const HEADER_PATTERN = header.pattern;
14
15
 
15
16
  // ------------------------------------------------------------------------------
16
17
  // Helpers
@@ -88,7 +89,7 @@ const createRegExpForIgnorePatterns = (normalizedOptions) => {
88
89
  // Rule Definition
89
90
  // ------------------------------------------------------------------------------
90
91
 
91
- module.exports = {
92
+ export default {
92
93
  meta: {
93
94
  type: 'layout',
94
95
 
@@ -0,0 +1,227 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { createRequire } from 'node:module';
6
+
7
+
8
+ const EXCLUDED_EFFECT_PACKAGES = ['@effect/vitest'];
9
+
10
+ /**
11
+ * ESLint rule to transform combined imports from 'effect' and '@effect/*'
12
+ * into subpath imports except for the EXCLUDED_EFFECT_PACKAGES.
13
+ * @example
14
+ * // before
15
+ * import { type Schema, SchemaAST } from 'effect';
16
+ *
17
+ * // after
18
+ * import type * as Schema from 'effect/Schema'
19
+ * import * as SchemaAST from 'effect/SchemaAST'
20
+ */
21
+ export default {
22
+ meta: {
23
+ type: 'suggestion',
24
+ docs: {
25
+ description: 'enforce subpath imports for Effect packages',
26
+ },
27
+ fixable: 'code',
28
+ schema: [],
29
+ },
30
+ create: (context) => {
31
+ // Resolver and caches are scoped to this lint run.
32
+ const requireForFile = createRequire(context.getFilename());
33
+ const exportsCache = new Map(); // packageName -> Set<segment>
34
+
35
+ const loadExportsForPackage = (pkgName) => {
36
+ if (exportsCache.has(pkgName)) return exportsCache.get(pkgName);
37
+ try {
38
+ const pkgJson = requireForFile(`${pkgName}/package.json`);
39
+ const ex = pkgJson && pkgJson.exports;
40
+ const segments = new Set();
41
+ if (ex && typeof ex === 'object') {
42
+ for (const key of Object.keys(ex)) {
43
+ // Keys like './Schema', './SchemaAST', './Function' (skip '.' and './package.json').
44
+ if (key === '.' || key === './package.json') continue;
45
+ if (key.startsWith('./')) segments.add(key.slice(2));
46
+ }
47
+ }
48
+ exportsCache.set(pkgName, segments);
49
+ return segments;
50
+ } catch {
51
+ const empty = new Set();
52
+ exportsCache.set(pkgName, empty);
53
+ return empty;
54
+ }
55
+ };
56
+
57
+ const isValidSubpath = (pkgName, segment) => {
58
+ const exported = loadExportsForPackage(pkgName);
59
+ return exported.has(segment);
60
+ };
61
+
62
+ const isEffectPackage = (source) => {
63
+ return source === 'effect' || source.startsWith('effect/') || source.startsWith('@effect/');
64
+ };
65
+
66
+ const shouldSkipEffectPackage = (basePackage) => {
67
+ return EXCLUDED_EFFECT_PACKAGES.includes(basePackage);
68
+ };
69
+
70
+ /**
71
+ * Get the base package name from a source string.
72
+ * @param {string} source - The source string to get the base package name from.
73
+ * @returns {string} The base package name.
74
+ * @example
75
+ * getBasePackage('effect/Schema') // 'effect'
76
+ * getBasePackage('@effect/ai/openai') // '@effect/ai'
77
+ */
78
+ const getBasePackage = (source) => {
79
+ if (source.startsWith('@')) {
80
+ const parts = source.split('/');
81
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : source;
82
+ } else {
83
+ return source.split('/')[0];
84
+ }
85
+ };
86
+
87
+ return {
88
+ ImportDeclaration: (node) => {
89
+ const source = String(node.source.value);
90
+ if (!isEffectPackage(source)) return;
91
+ const basePackage = getBasePackage(source);
92
+ if (shouldSkipEffectPackage(basePackage)) return;
93
+
94
+ // If it's a subpath import (e.g., 'effect/Schema'), enforce namespace import only.
95
+ if (source.startsWith(basePackage + '/')) {
96
+ const isNamespaceOnly =
97
+ node.specifiers.length === 1 && node.specifiers[0].type === 'ImportNamespaceSpecifier';
98
+ if (!isNamespaceOnly) {
99
+ context.report({
100
+ node,
101
+ message: 'Use namespace import for Effect subpaths',
102
+ });
103
+ }
104
+ return;
105
+ }
106
+
107
+ // From here on, we only handle root package imports like 'effect'.
108
+ const packageName = basePackage;
109
+
110
+ // Only process imports with specifiers.
111
+ if (!node.specifiers || node.specifiers.length === 0) {
112
+ return;
113
+ }
114
+
115
+ // Only process named imports.
116
+ const hasNamedImports = node.specifiers.some((spec) => spec.type === 'ImportSpecifier');
117
+
118
+ if (!hasNamedImports) {
119
+ return;
120
+ }
121
+
122
+ // Group specifiers by type.
123
+ const typeImports = [];
124
+ const regularImports = [];
125
+ for (const specifier of node.specifiers) {
126
+ if (specifier.type !== 'ImportSpecifier') continue;
127
+ const entry = { imported: specifier.imported.name, local: specifier.local.name };
128
+ if (specifier.importKind === 'type') typeImports.push(entry);
129
+ else regularImports.push(entry);
130
+ }
131
+
132
+ // Partition into resolvable vs unresolved specifiers.
133
+ const resolvedType = [];
134
+ const unresolvedType = [];
135
+ const resolvedRegular = [];
136
+ const unresolvedRegular = [];
137
+
138
+ typeImports.forEach((s) =>
139
+ isValidSubpath(packageName, s.imported) ? resolvedType.push(s) : unresolvedType.push(s),
140
+ );
141
+ regularImports.forEach((s) =>
142
+ isValidSubpath(packageName, s.imported) ? resolvedRegular.push(s) : unresolvedRegular.push(s),
143
+ );
144
+
145
+ const unresolved = [...unresolvedType, ...unresolvedRegular].map(({ imported }) => imported);
146
+
147
+ // Report and autofix: fix resolvable ones; keep unresolved in a remaining combined import.
148
+ context.report({
149
+ node,
150
+ message:
151
+ unresolved.length > 0
152
+ ? `Use subpath imports for Effect packages; unresolved kept in base import: ${unresolved.join(', ')}`
153
+ : 'Use subpath imports for Effect packages',
154
+ fix: (fixer) => {
155
+ const sourceCode = context.getSourceCode();
156
+ const imports = [];
157
+
158
+ // Idempotency guard: if nothing is resolvable, do not rewrite.
159
+ if (resolvedType.length === 0 && resolvedRegular.length === 0) {
160
+ return null;
161
+ }
162
+
163
+ // Prefer regular (value) imports over type imports on duplicates.
164
+ const seenResolved = new Set(); // key: `${alias}|${segment}`
165
+
166
+ // First, emit value imports and record keys.
167
+ resolvedRegular.forEach(({ imported, local }) => {
168
+ const alias = imported !== local ? local : imported;
169
+ const key = `${alias}|${imported}`;
170
+ if (seenResolved.has(key)) return;
171
+ seenResolved.add(key);
172
+ imports.push(`import * as ${alias} from '${packageName}/${imported}';`);
173
+ });
174
+
175
+ // Then, emit type imports only if a value import for the same alias/segment was not emitted.
176
+ resolvedType.forEach(({ imported, local }) => {
177
+ const alias = imported !== local ? local : imported;
178
+ const key = `${alias}|${imported}`;
179
+ if (seenResolved.has(key)) return; // skip type if value exists
180
+ seenResolved.add(key);
181
+ imports.push(`import type * as ${alias} from '${packageName}/${imported}';`);
182
+ });
183
+
184
+ // If there are unresolved, keep them in a single base import.
185
+ if (unresolvedType.length || unresolvedRegular.length) {
186
+ // Prefer value over type for the same local alias when both are present.
187
+ const byLocal = new Map(); // local -> { value?: {imported,local}, type?: {imported,local} }
188
+ unresolvedRegular.forEach((s) => {
189
+ const entry = byLocal.get(s.local) ?? {};
190
+ entry.value = s;
191
+ byLocal.set(s.local, entry);
192
+ });
193
+ unresolvedType.forEach((s) => {
194
+ const entry = byLocal.get(s.local) ?? {};
195
+ if (!entry.value) entry.type = s; // only keep type if no value for same local
196
+ byLocal.set(s.local, entry);
197
+ });
198
+
199
+ const specParts = [];
200
+ for (const entry of byLocal.values()) {
201
+ if (entry.value) {
202
+ const { imported, local } = entry.value;
203
+ const part = imported !== local ? `${imported} as ${local}` : `${imported}`;
204
+ specParts.push(part);
205
+ } else if (entry.type) {
206
+ const { imported, local } = entry.type;
207
+ const part = imported !== local ? `type ${imported} as ${local}` : `type ${imported}`;
208
+ specParts.push(part);
209
+ }
210
+ }
211
+ if (specParts.length) imports.push(`import { ${specParts.join(', ')} } from '${packageName}';`);
212
+ }
213
+
214
+ // Get the original import's indentation.
215
+ const importIndent = sourceCode.text.slice(node.range[0] - node.loc.start.column, node.range[0]);
216
+
217
+ // Join imports with newline and proper indentation.
218
+ if (imports.length === 0) return null; // nothing to change
219
+ const newImports = imports.join('\n' + importIndent);
220
+
221
+ return fixer.replaceText(node, newImports);
222
+ },
223
+ });
224
+ },
225
+ };
226
+ },
227
+ };
package/rules/header.js CHANGED
@@ -5,7 +5,7 @@
5
5
  const REGEX = /Copyright [0-9]+ DXOS.org/;
6
6
  const TEMPLATE = ['//', `// Copyright ${new Date().getFullYear()} DXOS.org`, '//', ''].join('\n') + '\n';
7
7
 
8
- module.exports = {
8
+ export default {
9
9
  pattern: REGEX,
10
10
  meta: {
11
11
  type: 'layout',
@@ -19,7 +19,7 @@ module.exports = {
19
19
  create: (context) => {
20
20
  return {
21
21
  Program: (node) => {
22
- if (!context.getSource().match(REGEX)) {
22
+ if (!context.sourceCode.getText().match(REGEX)) {
23
23
  context.report({
24
24
  node,
25
25
  message: 'Missing copyright header',
@@ -9,7 +9,7 @@ const isCatchCallSite = (expression) =>
9
9
  expression.callee.type === 'MemberExpression' &&
10
10
  expression.callee.property.name === 'catch';
11
11
 
12
- module.exports = {
12
+ export default {
13
13
  meta: {
14
14
  type: 'problem',
15
15
  fixable: 'code',
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": [
4
+ "**/*"
5
+ ],
6
+ "references": []
7
+ }
package/.eslintrc.cjs DELETED
@@ -1,9 +0,0 @@
1
- module.exports = {
2
- "extends": [
3
- "../../../.eslintrc.js"
4
- ],
5
- "parserOptions": {
6
- "project": "tsconfig.json",
7
- "tsconfigRootDir": __dirname,
8
- }
9
- }