@dxos/eslint-plugin-rules 0.8.4-main.c85a9c8dae → 0.8.4-main.d05673bc65
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 +3 -0
- package/package.json +1 -1
- package/rules/import-as-namespace.js +271 -0
package/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import comment from './rules/comment.js';
|
|
6
6
|
import effectSubpathImports from './rules/effect-subpath-imports.js';
|
|
7
7
|
import header from './rules/header.js';
|
|
8
|
+
import importAsNamespace from './rules/import-as-namespace.js';
|
|
8
9
|
import noBareDotImports from './rules/no-bare-dot-imports.js';
|
|
9
10
|
import noEffectRunPromise from './rules/no-effect-run-promise.js';
|
|
10
11
|
import noEmptyPromiseCatch from './rules/no-empty-promise-catch.js';
|
|
@@ -22,6 +23,7 @@ const plugin = {
|
|
|
22
23
|
comment,
|
|
23
24
|
'effect-subpath-imports': effectSubpathImports,
|
|
24
25
|
header,
|
|
26
|
+
'import-as-namespace': importAsNamespace,
|
|
25
27
|
'no-bare-dot-imports': noBareDotImports,
|
|
26
28
|
'no-effect-run-promise': noEffectRunPromise,
|
|
27
29
|
'no-empty-promise-catch': noEmptyPromiseCatch,
|
|
@@ -34,6 +36,7 @@ const plugin = {
|
|
|
34
36
|
rules: {
|
|
35
37
|
'dxos-plugin/effect-subpath-imports': 'error',
|
|
36
38
|
'dxos-plugin/header': 'error',
|
|
39
|
+
'dxos-plugin/import-as-namespace': 'error',
|
|
37
40
|
'dxos-plugin/no-bare-dot-imports': 'error',
|
|
38
41
|
'dxos-plugin/no-effect-run-promise': 'error',
|
|
39
42
|
'dxos-plugin/no-empty-promise-catch': 'error',
|
package/package.json
CHANGED
|
@@ -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
|
+
};
|