@dxos/eslint-plugin-rules 0.8.3 → 0.8.4-main.1068cf700f
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 +41 -4
- package/moon.yml +2 -0
- package/package.json +9 -2
- package/rules/comment.js +3 -2
- package/rules/effect-subpath-imports.js +284 -0
- package/rules/header.js +2 -2
- package/rules/no-effect-run-promise.js +52 -0
- package/rules/no-empty-promise-catch.js +1 -1
- package/tsconfig.json +7 -0
- package/.eslintrc.cjs +0 -9
- package/project.json +0 -12
package/index.js
CHANGED
|
@@ -2,10 +2,47 @@
|
|
|
2
2
|
// Copyright 2022 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
|
|
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 noEffectRunPromise from './rules/no-effect-run-promise.js';
|
|
9
|
+
import noEmptyPromiseCatch from './rules/no-empty-promise-catch.js';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
|
|
12
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
13
|
+
|
|
14
|
+
const plugin = {
|
|
15
|
+
meta: {
|
|
16
|
+
name: pkg.name,
|
|
17
|
+
version: pkg.version,
|
|
18
|
+
namespace: 'example',
|
|
19
|
+
},
|
|
6
20
|
rules: {
|
|
7
|
-
comment
|
|
8
|
-
|
|
9
|
-
|
|
21
|
+
comment,
|
|
22
|
+
'effect-subpath-imports': effectSubpathImports,
|
|
23
|
+
header,
|
|
24
|
+
'no-effect-run-promise': noEffectRunPromise,
|
|
25
|
+
'no-empty-promise-catch': noEmptyPromiseCatch,
|
|
26
|
+
},
|
|
27
|
+
configs: {
|
|
28
|
+
recommended: {
|
|
29
|
+
plugins: {
|
|
30
|
+
'dxos-plugin': null,
|
|
31
|
+
},
|
|
32
|
+
rules: {
|
|
33
|
+
'dxos-plugin/effect-subpath-imports': 'error',
|
|
34
|
+
'dxos-plugin/header': 'error',
|
|
35
|
+
'dxos-plugin/no-effect-run-promise': 'error',
|
|
36
|
+
'dxos-plugin/no-empty-promise-catch': 'error',
|
|
37
|
+
// TODO(dmaretskyi): Turned off due to large number of errors and no auto-fix.
|
|
38
|
+
// 'dxos-plugin/comment': 'error',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
10
41
|
},
|
|
11
42
|
};
|
|
43
|
+
|
|
44
|
+
Object.assign(plugin.configs.recommended.plugins, {
|
|
45
|
+
'dxos-plugin': plugin,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export default plugin;
|
package/moon.yml
ADDED
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/eslint-plugin-rules",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4-main.1068cf700f",
|
|
4
4
|
"homepage": "https://dxos.org",
|
|
5
5
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/dxos/dxos"
|
|
9
|
+
},
|
|
6
10
|
"license": "MIT",
|
|
7
11
|
"author": "info@dxos.org",
|
|
8
12
|
"sideEffects": true,
|
|
9
|
-
"
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./index.js"
|
|
16
|
+
},
|
|
10
17
|
"publishConfig": {
|
|
11
18
|
"access": "public"
|
|
12
19
|
}
|
package/rules/comment.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// Requires
|
|
11
11
|
// ------------------------------------------------------------------------------
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
92
|
+
export default {
|
|
92
93
|
meta: {
|
|
93
94
|
type: 'layout',
|
|
94
95
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
|
|
7
|
+
const EXCLUDED_EFFECT_PACKAGES = ['@effect/vitest'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Map of Effect base-package exports that come from a subpath (not a direct segment).
|
|
11
|
+
* Used when resolving imports like `import { pipe } from 'effect'` → effect/Function.
|
|
12
|
+
*/
|
|
13
|
+
const EFFECT_EXPORT_TO_SUBPATH = {
|
|
14
|
+
pipe: 'Function',
|
|
15
|
+
flow: 'Function',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Subpaths that allow named imports (e.g. `import { pipe, flow } from 'effect/Function'`).
|
|
20
|
+
* Other subpaths still require namespace imports.
|
|
21
|
+
*/
|
|
22
|
+
const NAMED_IMPORT_ALLOWED_SUBPATHS = new Set(['Function']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ESLint rule to transform combined imports from 'effect' and '@effect/*'
|
|
26
|
+
* into subpath imports except for the EXCLUDED_EFFECT_PACKAGES.
|
|
27
|
+
* @example
|
|
28
|
+
* // before
|
|
29
|
+
* import { type Schema, SchemaAST } from 'effect';
|
|
30
|
+
*
|
|
31
|
+
* // after
|
|
32
|
+
* import type * as Schema from 'effect/Schema'
|
|
33
|
+
* import * as SchemaAST from 'effect/SchemaAST'
|
|
34
|
+
*/
|
|
35
|
+
export default {
|
|
36
|
+
meta: {
|
|
37
|
+
type: 'suggestion',
|
|
38
|
+
docs: {
|
|
39
|
+
description: 'enforce subpath imports for Effect packages',
|
|
40
|
+
},
|
|
41
|
+
fixable: 'code',
|
|
42
|
+
schema: [],
|
|
43
|
+
},
|
|
44
|
+
create: (context) => {
|
|
45
|
+
// Resolver and caches are scoped to this lint run.
|
|
46
|
+
const requireForFile = createRequire(context.getFilename());
|
|
47
|
+
const exportsCache = new Map(); // packageName -> Set<segment>
|
|
48
|
+
|
|
49
|
+
const loadExportsForPackage = (pkgName) => {
|
|
50
|
+
if (exportsCache.has(pkgName)) return exportsCache.get(pkgName);
|
|
51
|
+
try {
|
|
52
|
+
const pkgJson = requireForFile(`${pkgName}/package.json`);
|
|
53
|
+
const ex = pkgJson && pkgJson.exports;
|
|
54
|
+
const segments = new Set();
|
|
55
|
+
if (ex && typeof ex === 'object') {
|
|
56
|
+
for (const key of Object.keys(ex)) {
|
|
57
|
+
// Keys like './Schema', './SchemaAST', './Function' (skip '.' and './package.json').
|
|
58
|
+
if (key === '.' || key === './package.json') continue;
|
|
59
|
+
if (key.startsWith('./')) segments.add(key.slice(2));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exportsCache.set(pkgName, segments);
|
|
63
|
+
return segments;
|
|
64
|
+
} catch {
|
|
65
|
+
const empty = new Set();
|
|
66
|
+
exportsCache.set(pkgName, empty);
|
|
67
|
+
return empty;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isValidSubpath = (pkgName, segment) => {
|
|
72
|
+
const exported = loadExportsForPackage(pkgName);
|
|
73
|
+
return exported.has(segment);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const resolveExportToSegment = (pkgName, exportName) => {
|
|
77
|
+
if (isValidSubpath(pkgName, exportName)) return exportName;
|
|
78
|
+
if (pkgName === 'effect' && EFFECT_EXPORT_TO_SUBPATH[exportName]) {
|
|
79
|
+
const segment = EFFECT_EXPORT_TO_SUBPATH[exportName];
|
|
80
|
+
return isValidSubpath(pkgName, segment) ? segment : null;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isEffectPackage = (source) => {
|
|
86
|
+
return source === 'effect' || source.startsWith('effect/') || source.startsWith('@effect/');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const shouldSkipEffectPackage = (basePackage) => {
|
|
90
|
+
return EXCLUDED_EFFECT_PACKAGES.includes(basePackage);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the base package name from a source string.
|
|
95
|
+
* @param {string} source - The source string to get the base package name from.
|
|
96
|
+
* @returns {string} The base package name.
|
|
97
|
+
* @example
|
|
98
|
+
* getBasePackage('effect/Schema') // 'effect'
|
|
99
|
+
* getBasePackage('@effect/ai/openai') // '@effect/ai'
|
|
100
|
+
*/
|
|
101
|
+
const getBasePackage = (source) => {
|
|
102
|
+
if (source.startsWith('@')) {
|
|
103
|
+
const parts = source.split('/');
|
|
104
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : source;
|
|
105
|
+
} else {
|
|
106
|
+
return source.split('/')[0];
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ImportDeclaration: (node) => {
|
|
112
|
+
const source = String(node.source.value);
|
|
113
|
+
if (!isEffectPackage(source)) return;
|
|
114
|
+
const basePackage = getBasePackage(source);
|
|
115
|
+
if (shouldSkipEffectPackage(basePackage)) return;
|
|
116
|
+
|
|
117
|
+
// If it's a subpath import (e.g., 'effect/Schema'), enforce namespace import except for allowed subpaths.
|
|
118
|
+
if (source.startsWith(basePackage + '/')) {
|
|
119
|
+
const segment = source.slice(basePackage.length + 1);
|
|
120
|
+
const allowsNamed = NAMED_IMPORT_ALLOWED_SUBPATHS.has(segment);
|
|
121
|
+
const isNamespaceOnly =
|
|
122
|
+
node.specifiers.length === 1 && node.specifiers[0].type === 'ImportNamespaceSpecifier';
|
|
123
|
+
if (!allowsNamed && !isNamespaceOnly) {
|
|
124
|
+
context.report({
|
|
125
|
+
node,
|
|
126
|
+
message: 'Use namespace import for Effect subpaths',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// From here on, we only handle root package imports like 'effect'.
|
|
133
|
+
const packageName = basePackage;
|
|
134
|
+
|
|
135
|
+
// Only process imports with specifiers.
|
|
136
|
+
if (!node.specifiers || node.specifiers.length === 0) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Only process named imports.
|
|
141
|
+
const hasNamedImports = node.specifiers.some((spec) => spec.type === 'ImportSpecifier');
|
|
142
|
+
|
|
143
|
+
if (!hasNamedImports) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Group specifiers by type.
|
|
148
|
+
const typeImports = [];
|
|
149
|
+
const regularImports = [];
|
|
150
|
+
for (const specifier of node.specifiers) {
|
|
151
|
+
if (specifier.type !== 'ImportSpecifier') continue;
|
|
152
|
+
const entry = { imported: specifier.imported.name, local: specifier.local.name };
|
|
153
|
+
if (specifier.importKind === 'type') typeImports.push(entry);
|
|
154
|
+
else regularImports.push(entry);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Partition into resolvable vs unresolved specifiers (resolved entries include segment for fix).
|
|
158
|
+
const resolvedType = [];
|
|
159
|
+
const unresolvedType = [];
|
|
160
|
+
const resolvedRegular = [];
|
|
161
|
+
const unresolvedRegular = [];
|
|
162
|
+
|
|
163
|
+
typeImports.forEach((spec) => {
|
|
164
|
+
const segment = resolveExportToSegment(packageName, spec.imported);
|
|
165
|
+
if (segment) resolvedType.push({ ...spec, segment });
|
|
166
|
+
else unresolvedType.push(spec);
|
|
167
|
+
});
|
|
168
|
+
regularImports.forEach((spec) => {
|
|
169
|
+
const segment = resolveExportToSegment(packageName, spec.imported);
|
|
170
|
+
if (segment) resolvedRegular.push({ ...spec, segment });
|
|
171
|
+
else unresolvedRegular.push(spec);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const unresolved = [...unresolvedType, ...unresolvedRegular].map(({ imported }) => imported);
|
|
175
|
+
|
|
176
|
+
// Report and autofix: fix resolvable ones; keep unresolved in a remaining combined import.
|
|
177
|
+
context.report({
|
|
178
|
+
node,
|
|
179
|
+
message:
|
|
180
|
+
unresolved.length > 0
|
|
181
|
+
? `Use subpath imports for Effect packages; unresolved kept in base import: ${unresolved.join(', ')}`
|
|
182
|
+
: 'Use subpath imports for Effect packages',
|
|
183
|
+
fix: (fixer) => {
|
|
184
|
+
const sourceCode = context.getSourceCode();
|
|
185
|
+
const imports = [];
|
|
186
|
+
|
|
187
|
+
// Idempotency guard: if nothing is resolvable, do not rewrite.
|
|
188
|
+
if (resolvedType.length === 0 && resolvedRegular.length === 0) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Group resolved imports by segment.
|
|
193
|
+
const bySegment = new Map(); // segment -> { regular: [...], type: [...] }
|
|
194
|
+
resolvedRegular.forEach((entry) => {
|
|
195
|
+
const seg = entry.segment;
|
|
196
|
+
let group = bySegment.get(seg);
|
|
197
|
+
if (!group) {
|
|
198
|
+
group = { regular: [], type: [] };
|
|
199
|
+
bySegment.set(seg, group);
|
|
200
|
+
}
|
|
201
|
+
group.regular.push(entry);
|
|
202
|
+
});
|
|
203
|
+
resolvedType.forEach((entry) => {
|
|
204
|
+
const seg = entry.segment;
|
|
205
|
+
let group = bySegment.get(seg);
|
|
206
|
+
if (!group) {
|
|
207
|
+
group = { regular: [], type: [] };
|
|
208
|
+
bySegment.set(seg, group);
|
|
209
|
+
}
|
|
210
|
+
group.type.push(entry);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
for (const [segment, group] of bySegment) {
|
|
214
|
+
const useNamed = NAMED_IMPORT_ALLOWED_SUBPATHS.has(segment);
|
|
215
|
+
const merged = [...group.regular];
|
|
216
|
+
for (const t of group.type) {
|
|
217
|
+
if (!group.regular.some((r) => r.local === t.local)) merged.push(t);
|
|
218
|
+
}
|
|
219
|
+
if (useNamed && merged.length > 0) {
|
|
220
|
+
const specParts = merged.map(({ imported, local }) =>
|
|
221
|
+
imported !== local ? `${imported} as ${local}` : imported,
|
|
222
|
+
);
|
|
223
|
+
imports.push(`import { ${specParts.join(', ')} } from '${packageName}/${segment}';`);
|
|
224
|
+
} else {
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
for (const { imported, local } of merged) {
|
|
227
|
+
const alias = imported !== local ? local : imported;
|
|
228
|
+
if (seen.has(alias)) continue;
|
|
229
|
+
seen.add(alias);
|
|
230
|
+
const isTypeOnly =
|
|
231
|
+
group.type.some((t) => t.imported === imported) &&
|
|
232
|
+
!group.regular.some((r) => r.imported === imported);
|
|
233
|
+
const importStr = isTypeOnly
|
|
234
|
+
? `import type * as ${alias} from '${packageName}/${segment}';`
|
|
235
|
+
: `import * as ${alias} from '${packageName}/${segment}';`;
|
|
236
|
+
imports.push(importStr);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// If there are unresolved, keep them in a single base import.
|
|
242
|
+
if (unresolvedType.length || unresolvedRegular.length) {
|
|
243
|
+
// Prefer value over type for the same local alias when both are present.
|
|
244
|
+
const byLocal = new Map(); // local -> { value?: {imported,local}, type?: {imported,local} }
|
|
245
|
+
unresolvedRegular.forEach((s) => {
|
|
246
|
+
const entry = byLocal.get(s.local) ?? {};
|
|
247
|
+
entry.value = s;
|
|
248
|
+
byLocal.set(s.local, entry);
|
|
249
|
+
});
|
|
250
|
+
unresolvedType.forEach((s) => {
|
|
251
|
+
const entry = byLocal.get(s.local) ?? {};
|
|
252
|
+
if (!entry.value) entry.type = s; // only keep type if no value for same local
|
|
253
|
+
byLocal.set(s.local, entry);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const specParts = [];
|
|
257
|
+
for (const entry of byLocal.values()) {
|
|
258
|
+
if (entry.value) {
|
|
259
|
+
const { imported, local } = entry.value;
|
|
260
|
+
const part = imported !== local ? `${imported} as ${local}` : `${imported}`;
|
|
261
|
+
specParts.push(part);
|
|
262
|
+
} else if (entry.type) {
|
|
263
|
+
const { imported, local } = entry.type;
|
|
264
|
+
const part = imported !== local ? `type ${imported} as ${local}` : `type ${imported}`;
|
|
265
|
+
specParts.push(part);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (specParts.length) imports.push(`import { ${specParts.join(', ')} } from '${packageName}';`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Get the original import's indentation.
|
|
272
|
+
const importIndent = sourceCode.text.slice(node.range[0] - node.loc.start.column, node.range[0]);
|
|
273
|
+
|
|
274
|
+
// Join imports with newline and proper indentation.
|
|
275
|
+
if (imports.length === 0) return null; // nothing to change
|
|
276
|
+
const newImports = imports.join('\n' + importIndent);
|
|
277
|
+
|
|
278
|
+
return fixer.replaceText(node, newImports);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
};
|
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
|
-
|
|
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.
|
|
22
|
+
if (!context.sourceCode.getText().match(REGEX)) {
|
|
23
23
|
context.report({
|
|
24
24
|
node,
|
|
25
25
|
message: 'Missing copyright header',
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint rule to prevent usage of Effect.runPromise and Effect.runPromiseExit,
|
|
9
|
+
* and suggest runAndForwardErrors instead.
|
|
10
|
+
* @example
|
|
11
|
+
* // bad
|
|
12
|
+
* await Effect.runPromise(myEffect);
|
|
13
|
+
* await Effect.runPromiseExit(myEffect);
|
|
14
|
+
*
|
|
15
|
+
* // good
|
|
16
|
+
* await runAndForwardErrors(myEffect);
|
|
17
|
+
*/
|
|
18
|
+
export default {
|
|
19
|
+
meta: {
|
|
20
|
+
type: 'problem',
|
|
21
|
+
docs: {
|
|
22
|
+
description: 'Disallow Effect.runPromise; suggest runAndForwardErrors instead.',
|
|
23
|
+
recommended: true,
|
|
24
|
+
},
|
|
25
|
+
messages: {
|
|
26
|
+
noRunPromise: 'Use runAndForwardErrors from @dxos/effect instead of Effect.runPromise.',
|
|
27
|
+
},
|
|
28
|
+
schema: [],
|
|
29
|
+
},
|
|
30
|
+
create(context) {
|
|
31
|
+
return {
|
|
32
|
+
CallExpression(node) {
|
|
33
|
+
// Check if this is Effect.runPromise or Effect.runPromiseExit
|
|
34
|
+
const isEffectMethod =
|
|
35
|
+
node.callee.type === 'MemberExpression' &&
|
|
36
|
+
node.callee.object.type === 'Identifier' &&
|
|
37
|
+
node.callee.object.name === 'Effect' &&
|
|
38
|
+
node.callee.property.type === 'Identifier';
|
|
39
|
+
|
|
40
|
+
if (isEffectMethod) {
|
|
41
|
+
const methodName = node.callee.property.name;
|
|
42
|
+
if (methodName === 'runPromise') {
|
|
43
|
+
context.report({
|
|
44
|
+
node,
|
|
45
|
+
messageId: 'noRunPromise',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
package/tsconfig.json
ADDED
package/.eslintrc.cjs
DELETED
package/project.json
DELETED