@dxos/eslint-plugin-rules 0.8.4-main.84f28bd → 0.8.4-main.ae835ea
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 +38 -4
- package/package.json +5 -2
- package/rules/comment.js +3 -2
- package/rules/effect-subpath-imports.js +227 -0
- package/rules/header.js +2 -2
- package/rules/no-empty-promise-catch.js +1 -1
- package/tsconfig.json +7 -0
- package/.eslintrc.cjs +0 -9
package/index.js
CHANGED
|
@@ -2,10 +2,44 @@
|
|
|
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 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
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.4-main.ae835ea",
|
|
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
|
-
"
|
|
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
|
-
|
|
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,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
|
-
|
|
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',
|
package/tsconfig.json
ADDED