@dxos/eslint-plugin-rules 0.8.4-main.ae835ea → 0.8.4-main.bbf232bc24
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/docs/2025-04-03-translation-key-normalization-design.md +94 -0
- package/docs/README.md +1 -3
- package/index.js +15 -1
- package/moon.yml +1 -1
- package/package.json +5 -1
- package/rules/consistent-update-param.js +143 -0
- package/rules/effect-subpath-imports.js +130 -40
- package/rules/import-as-namespace.js +271 -0
- package/rules/no-bare-dot-imports.js +55 -0
- package/rules/no-effect-run-promise.js +52 -0
- package/rules/translation-key-format.js +443 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const DIRECTIVE_TEXT = '@import-as-namespace';
|
|
9
|
+
const DIRECTIVE_LINE_REGEX = /^\s*\/\/\s*@import-as-namespace\s*$/m;
|
|
10
|
+
const PASCAL_CASE_REGEX = /^[A-Z][a-zA-Z0-9]*$/;
|
|
11
|
+
const TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ESLint rule to enforce namespace imports for modules annotated with `// @import-as-namespace`.
|
|
15
|
+
*
|
|
16
|
+
* When a module contains the `// @import-as-namespace` directive comment, this rule enforces:
|
|
17
|
+
* - The module filename is PascalCase (e.g. `LanguageModel.ts`).
|
|
18
|
+
* - All imports of the module use namespace form: `import * as LanguageModel from './LanguageModel'`.
|
|
19
|
+
* - The namespace name matches the filename (without extension), or has a `Module` suffix.
|
|
20
|
+
* - Re-exports use namespace form: `export * as LanguageModel from './LanguageModel'`.
|
|
21
|
+
*
|
|
22
|
+
* The `Module` suffix is allowed as an escape hatch when the expected namespace name conflicts
|
|
23
|
+
* with a local declaration in the importing file (e.g., `import * as ObjModule from './Obj'`
|
|
24
|
+
* when the file also exports its own `Obj` interface).
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // In LanguageModel.ts:
|
|
28
|
+
* // @import-as-namespace
|
|
29
|
+
* export const foo = 1;
|
|
30
|
+
*
|
|
31
|
+
* // ❌ Bad (in another file):
|
|
32
|
+
* import { foo } from './LanguageModel';
|
|
33
|
+
* export { foo } from './LanguageModel';
|
|
34
|
+
* export * from './LanguageModel';
|
|
35
|
+
*
|
|
36
|
+
* // ✅ Good:
|
|
37
|
+
* import * as LanguageModel from './LanguageModel';
|
|
38
|
+
* import * as LanguageModelModule from './LanguageModel'; // Also allowed when needed
|
|
39
|
+
* export * as LanguageModel from './LanguageModel';
|
|
40
|
+
*/
|
|
41
|
+
export default {
|
|
42
|
+
meta: {
|
|
43
|
+
type: 'problem',
|
|
44
|
+
docs: {
|
|
45
|
+
description: 'enforce namespace imports for modules marked with @import-as-namespace',
|
|
46
|
+
},
|
|
47
|
+
fixable: 'code',
|
|
48
|
+
schema: [],
|
|
49
|
+
messages: {
|
|
50
|
+
filenameMustBePascalCase:
|
|
51
|
+
'Module marked with @import-as-namespace must have a PascalCase filename. Got: "{{filename}}".',
|
|
52
|
+
mustUseNamespaceImport:
|
|
53
|
+
'Module "{{source}}" is marked @import-as-namespace. Use: `import * as {{namespace}} from \'{{source}}\'`.',
|
|
54
|
+
namespaceMustMatchFilename: 'Namespace import name "{{actual}}" must match filename "{{expected}}".',
|
|
55
|
+
mustUseNamespaceReexport:
|
|
56
|
+
'Module "{{source}}" is marked @import-as-namespace. Use: `export * as {{namespace}} from \'{{source}}\'`.',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
create: (context) => {
|
|
60
|
+
const directiveCache = new Map();
|
|
61
|
+
|
|
62
|
+
const fileHasDirective = (filePath) => {
|
|
63
|
+
if (directiveCache.has(filePath)) {
|
|
64
|
+
return directiveCache.get(filePath);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
68
|
+
const result = DIRECTIVE_LINE_REGEX.test(content);
|
|
69
|
+
directiveCache.set(filePath, result);
|
|
70
|
+
return result;
|
|
71
|
+
} catch {
|
|
72
|
+
directiveCache.set(filePath, false);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const resolveRelativeImport = (source, currentFile) => {
|
|
78
|
+
if (!source.startsWith('.')) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const dir = path.dirname(currentFile);
|
|
82
|
+
const resolved = path.resolve(dir, source);
|
|
83
|
+
for (const ext of TS_EXTENSIONS) {
|
|
84
|
+
const filePath = resolved + ext;
|
|
85
|
+
if (fs.existsSync(filePath)) {
|
|
86
|
+
return filePath;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const ext of TS_EXTENSIONS) {
|
|
90
|
+
const filePath = path.join(resolved, 'index' + ext);
|
|
91
|
+
if (fs.existsSync(filePath)) {
|
|
92
|
+
return filePath;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const namespaceFromSource = (source) => {
|
|
99
|
+
const parts = source.split('/');
|
|
100
|
+
const last = parts[parts.length - 1];
|
|
101
|
+
return last.replace(/\.\w+$/, '');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const buildImportFix = (fixer, node, source, expectedNamespace, context) => {
|
|
105
|
+
const fixes = [];
|
|
106
|
+
const importKeyword = node.importKind === 'type' ? 'import type' : 'import';
|
|
107
|
+
fixes.push(fixer.replaceText(node, `${importKeyword} * as ${expectedNamespace} from '${source}';`));
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const declaredVars = context.sourceCode.getDeclaredVariables(node);
|
|
111
|
+
for (const variable of declaredVars) {
|
|
112
|
+
for (const ref of variable.references) {
|
|
113
|
+
if (ref.identifier.range[0] >= node.range[0] && ref.identifier.range[1] <= node.range[1]) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
fixes.push(fixer.replaceText(ref.identifier, `${expectedNamespace}.${variable.name}`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Scope analysis unavailable; fix import declaration only.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return fixes;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
Program: (node) => {
|
|
128
|
+
const comments = context.sourceCode.getAllComments();
|
|
129
|
+
const hasDirective = comments.some(
|
|
130
|
+
(comment) => comment.type === 'Line' && comment.value.trim() === DIRECTIVE_TEXT,
|
|
131
|
+
);
|
|
132
|
+
if (!hasDirective) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const filename = path.basename(context.getFilename());
|
|
136
|
+
const stem = filename.replace(/\.\w+$/, '');
|
|
137
|
+
if (!PASCAL_CASE_REGEX.test(stem)) {
|
|
138
|
+
context.report({
|
|
139
|
+
node,
|
|
140
|
+
messageId: 'filenameMustBePascalCase',
|
|
141
|
+
data: { filename },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
ImportDeclaration: (node) => {
|
|
147
|
+
const source = String(node.source.value);
|
|
148
|
+
if (!source.startsWith('.')) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!node.specifiers || node.specifiers.length === 0) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const currentFile = context.getFilename();
|
|
156
|
+
const resolved = resolveRelativeImport(source, currentFile);
|
|
157
|
+
if (!resolved) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!fileHasDirective(resolved)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const expectedNamespace = namespaceFromSource(source);
|
|
165
|
+
|
|
166
|
+
const isNamespaceImport =
|
|
167
|
+
node.specifiers.length === 1 && node.specifiers[0].type === 'ImportNamespaceSpecifier';
|
|
168
|
+
|
|
169
|
+
if (!isNamespaceImport) {
|
|
170
|
+
context.report({
|
|
171
|
+
node,
|
|
172
|
+
messageId: 'mustUseNamespaceImport',
|
|
173
|
+
data: { source, namespace: expectedNamespace },
|
|
174
|
+
fix: (fixer) => buildImportFix(fixer, node, source, expectedNamespace, context),
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const actual = node.specifiers[0].local.name;
|
|
180
|
+
const allowedNames = [expectedNamespace, expectedNamespace + 'Module'];
|
|
181
|
+
if (!allowedNames.includes(actual)) {
|
|
182
|
+
context.report({
|
|
183
|
+
node,
|
|
184
|
+
messageId: 'namespaceMustMatchFilename',
|
|
185
|
+
data: { actual, expected: expectedNamespace },
|
|
186
|
+
fix: (fixer) => {
|
|
187
|
+
const importKeyword = node.importKind === 'type' ? 'import type' : 'import';
|
|
188
|
+
return fixer.replaceText(node, `${importKeyword} * as ${expectedNamespace} from '${source}';`);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
ExportNamedDeclaration: (node) => {
|
|
195
|
+
if (!node.source) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const source = String(node.source.value);
|
|
199
|
+
if (!source.startsWith('.')) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const currentFile = context.getFilename();
|
|
204
|
+
const resolved = resolveRelativeImport(source, currentFile);
|
|
205
|
+
if (!resolved) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!fileHasDirective(resolved)) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const expectedNamespace = namespaceFromSource(source);
|
|
213
|
+
const exportKeyword = node.exportKind === 'type' ? 'export type' : 'export';
|
|
214
|
+
context.report({
|
|
215
|
+
node,
|
|
216
|
+
messageId: 'mustUseNamespaceReexport',
|
|
217
|
+
data: { source, namespace: expectedNamespace },
|
|
218
|
+
fix: (fixer) => {
|
|
219
|
+
return fixer.replaceText(node, `${exportKeyword} * as ${expectedNamespace} from '${source}';`);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
ExportAllDeclaration: (node) => {
|
|
225
|
+
if (!node.source) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const source = String(node.source.value);
|
|
229
|
+
if (!source.startsWith('.')) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const currentFile = context.getFilename();
|
|
234
|
+
const resolved = resolveRelativeImport(source, currentFile);
|
|
235
|
+
if (!resolved) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!fileHasDirective(resolved)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const expectedNamespace = namespaceFromSource(source);
|
|
243
|
+
|
|
244
|
+
if (node.exported) {
|
|
245
|
+
const actual = node.exported.name;
|
|
246
|
+
const allowedNames = [expectedNamespace, expectedNamespace + 'Module'];
|
|
247
|
+
if (!allowedNames.includes(actual)) {
|
|
248
|
+
context.report({
|
|
249
|
+
node,
|
|
250
|
+
messageId: 'namespaceMustMatchFilename',
|
|
251
|
+
data: { actual, expected: expectedNamespace },
|
|
252
|
+
fix: (fixer) => {
|
|
253
|
+
return fixer.replaceText(node, `export * as ${expectedNamespace} from '${source}';`);
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
context.report({
|
|
261
|
+
node,
|
|
262
|
+
messageId: 'mustUseNamespaceReexport',
|
|
263
|
+
data: { source, namespace: expectedNamespace },
|
|
264
|
+
fix: (fixer) => {
|
|
265
|
+
return fixer.replaceText(node, `export * as ${expectedNamespace} from '${source}';`);
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ESLint rule to prevent bare "." or ".." imports.
|
|
7
|
+
* These imports are ambiguous as they don't explicitly show which file is being imported.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // ❌ Bad
|
|
11
|
+
* import { foo } from '.';
|
|
12
|
+
* import { bar } from '..';
|
|
13
|
+
*
|
|
14
|
+
* // ✅ Good
|
|
15
|
+
* import { foo } from './index';
|
|
16
|
+
* import { bar } from '../index';
|
|
17
|
+
*/
|
|
18
|
+
export default {
|
|
19
|
+
meta: {
|
|
20
|
+
type: 'problem',
|
|
21
|
+
docs: {
|
|
22
|
+
description: 'disallow bare "." or ".." in import paths',
|
|
23
|
+
category: 'Best Practices',
|
|
24
|
+
recommended: true,
|
|
25
|
+
},
|
|
26
|
+
fixable: 'code',
|
|
27
|
+
schema: [],
|
|
28
|
+
messages: {
|
|
29
|
+
bareDotImport: 'Use explicit path instead of bare "{{source}}". Consider "{{suggestion}}" instead.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
create: (context) => {
|
|
33
|
+
return {
|
|
34
|
+
ImportDeclaration: (node) => {
|
|
35
|
+
const source = node.source.value;
|
|
36
|
+
|
|
37
|
+
// Check if the import is exactly "." or ".."
|
|
38
|
+
if (source === '.' || source === '..') {
|
|
39
|
+
context.report({
|
|
40
|
+
node: node.source,
|
|
41
|
+
messageId: 'bareDotImport',
|
|
42
|
+
data: {
|
|
43
|
+
source,
|
|
44
|
+
suggestion: source === '.' ? './index' : '../index',
|
|
45
|
+
},
|
|
46
|
+
fix: (fixer) => {
|
|
47
|
+
const newSource = source === '.' ? './index' : '../index';
|
|
48
|
+
return fixer.replaceText(node.source, `'${newSource}'`);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -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
|
+
};
|