eslint-plugin-yenz 2.1.1 → 2.2.0-beta.1
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/README.md +56 -12
- package/index.js +2 -0
- package/lib/rules/export-at-end-of-file.js +229 -0
- package/package.json +1 -1
- package/test/eslint.config.js +1 -0
- package/test/fixtures.ts +9 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ type C = string | number | null | undefined
|
|
|
73
73
|
|
|
74
74
|
Disallows `for`, `while`, and `do...while` loops. `for...of` and `for...in` are allowed.
|
|
75
75
|
|
|
76
|
-
> **Note:** This rule is
|
|
76
|
+
> **Note:** This rule is *not* enabled in the `recommended` preset. Enable it explicitly or use the `all` preset.
|
|
77
77
|
|
|
78
78
|
**Bad:**
|
|
79
79
|
|
|
@@ -96,7 +96,7 @@ items.map(item => transform(item))
|
|
|
96
96
|
|
|
97
97
|
Disallows arrow functions assigned to named variables. Prefer function declarations instead. Auto-fixable.
|
|
98
98
|
|
|
99
|
-
> **Note:** This rule is
|
|
99
|
+
> **Note:** This rule is *not* enabled in the `recommended` preset. Enable it explicitly or use the `all` preset.
|
|
100
100
|
|
|
101
101
|
**Bad:**
|
|
102
102
|
|
|
@@ -123,8 +123,8 @@ class Foo { bar = () => {} }
|
|
|
123
123
|
|
|
124
124
|
## Preset Configurations
|
|
125
125
|
|
|
126
|
-
- **`
|
|
127
|
-
- **`
|
|
126
|
+
- `**recommended**` - Enables `type-ordering` as error and `no-loops` as warning
|
|
127
|
+
- `**all**` - Enables all rules (`type-ordering`, `no-loops`, `no-named-arrow-functions`) as errors
|
|
128
128
|
|
|
129
129
|
# Release Procedure
|
|
130
130
|
|
|
@@ -134,20 +134,64 @@ class Foo { bar = () => {} }
|
|
|
134
134
|
4. Add code samples in `test/` that intentionally fail your new or updated rules to confirm they are caught.
|
|
135
135
|
5. Commit and push your changes, then open a PR.
|
|
136
136
|
6. **Bump to a pre-release version and publish a beta:**
|
|
137
|
-
|
|
137
|
+
```bash
|
|
138
138
|
yarn version --pre[major|minor|patch] --preid beta
|
|
139
139
|
npm publish --tag beta # or alpha, rc
|
|
140
|
-
|
|
140
|
+
```
|
|
141
141
|
Users can test it with:
|
|
142
|
-
```bash
|
|
143
|
-
yarn add eslint-plugin-yenz@beta # or @alpha, @rc
|
|
144
|
-
```
|
|
145
142
|
7. After review, **merge your branch into `main`**.
|
|
146
143
|
8. Open a version bump PR against `main` and merge it in.
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
9. **Publish the stable release** from `main`:
|
|
145
|
+
```bash
|
|
149
146
|
yarn version --[major|minor|patch]
|
|
150
147
|
npm publish
|
|
151
|
-
|
|
148
|
+
```
|
|
152
149
|
|
|
153
150
|
> **Why `yarn version` + `npm publish`?** `yarn version` handles the version bump, git tag, and commit. We use `npm publish` for the actual publish because `yarn publish` redundantly prompts for a new version even when one was already set.
|
|
151
|
+
|
|
152
|
+
# Development
|
|
153
|
+
- Use https://astexplorer.net/ to easily get the AST types for your changes
|
|
154
|
+
|
|
155
|
+
## Adding tests
|
|
156
|
+
|
|
157
|
+
Tests are fixture-based. All test cases live in a single file, `test/fixtures.ts`, and the runner (`test/run.js`) lints that file with ESLint, parses the JSON output, and compares it against inline annotations.
|
|
158
|
+
|
|
159
|
+
### Annotations
|
|
160
|
+
|
|
161
|
+
Each line in `test/fixtures.ts` may carry one or both of the following trailing comments:
|
|
162
|
+
|
|
163
|
+
- `// expect-error <ruleId>` — the line must produce a violation reported by `<ruleId>` (e.g. `yenz/no-loops`).
|
|
164
|
+
- `// fix: <expected-code>` — after running ESLint with `--fix-dry-run`, the line (with the `// expect-error ...` annotation stripped) must equal `<expected-code>`.
|
|
165
|
+
|
|
166
|
+
Lines without `// expect-error` must produce **no** violations. Unexpected violations fail the test, just like missing ones.
|
|
167
|
+
|
|
168
|
+
### Adding a positive case (rule should fire)
|
|
169
|
+
|
|
170
|
+
Add a line that violates the rule and annotate it:
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
const foo = () => {} // expect-error yenz/no-named-arrow-functions
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
If the rule is auto-fixable, also include the expected fixed output:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
const foo = () => {} // expect-error yenz/no-named-arrow-functions // fix: function foo() {}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Adding a negative case (rule should not fire)
|
|
183
|
+
|
|
184
|
+
Add the code with no annotation. A short `// Should pass:` comment above the block keeps the file readable:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// Should pass:
|
|
188
|
+
const arr = [1, 2, 3].map(x => x)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Running tests
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
yarn test
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The runner reports `Violations: X/Y passed` and `Fixes: X/Y passed`, and exits non-zero on any missing violation, unexpected violation, or fix mismatch.
|
package/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import typeOrdering from './lib/rules/type-ordering.js';
|
|
2
2
|
import noLoops from './lib/rules/no-loops.js';
|
|
3
3
|
import noNamedArrowFunctions from './lib/rules/no-named-arrow-functions.js';
|
|
4
|
+
import exportAtEndOfFile from './lib/rules/export-at-end-of-file.js';
|
|
4
5
|
|
|
5
6
|
const plugin = {
|
|
6
7
|
rules: {
|
|
7
8
|
'type-ordering': typeOrdering,
|
|
8
9
|
'no-loops': noLoops,
|
|
9
10
|
'no-named-arrow-functions': noNamedArrowFunctions,
|
|
11
|
+
'export-at-end-of-file': exportAtEndOfFile,
|
|
10
12
|
},
|
|
11
13
|
};
|
|
12
14
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// AST node types we treat as "exportable declarations" — i.e. things you can
|
|
2
|
+
// inline-export with `export function`, `export class`, `export type`, or
|
|
3
|
+
// `export interface`. The `TS*` types come from typescript-eslint and only
|
|
4
|
+
// appear when the file is parsed with the TS parser.
|
|
5
|
+
const EXPORTABLE_DECLARATION_TYPES = new Set([
|
|
6
|
+
'FunctionDeclaration',
|
|
7
|
+
'ClassDeclaration',
|
|
8
|
+
'TSTypeAliasDeclaration',
|
|
9
|
+
'TSInterfaceDeclaration',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
// Subset of the above that exists only at the type level. When listed at the
|
|
13
|
+
// bottom of the file these need either `export type { … }` or per-name
|
|
14
|
+
// `type` markers so tools like `verbatimModuleSyntax` keep them erased.
|
|
15
|
+
const TYPE_ONLY_DECLARATION_TYPES = new Set([
|
|
16
|
+
'TSTypeAliasDeclaration',
|
|
17
|
+
'TSInterfaceDeclaration',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determines whether an AST node is an inline-exported declaration that this
|
|
22
|
+
* rule should flag. Matches statements like `export function foo() {}` or
|
|
23
|
+
* `export type Foo = …`, but ignores re-exports (`export { x } from '…'`)
|
|
24
|
+
* and bare specifier exports (`export { x }`).
|
|
25
|
+
*
|
|
26
|
+
* @param {object} node - Top-level program statement node.
|
|
27
|
+
* @returns {boolean} True when the node is an inline `export <decl>` form
|
|
28
|
+
* covering function, class, type alias, or interface declarations.
|
|
29
|
+
*/
|
|
30
|
+
function isInlineExportableDeclaration(node) {
|
|
31
|
+
// `node.source` is non-null for re-exports (`export { x } from '…'`) and
|
|
32
|
+
// `node.declaration` is null for bare specifier exports (`export { x }`).
|
|
33
|
+
if (node.type !== 'ExportNamedDeclaration' || node.source || !node.declaration) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const { declaration } = node;
|
|
37
|
+
// `declaration.id` is null for anonymous default-export-style declarations,
|
|
38
|
+
// which don't apply here but we guard against it for safety.
|
|
39
|
+
return EXPORTABLE_DECLARATION_TYPES.has(declaration.type) && Boolean(declaration.id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extracts the exported name (and whether it's type-only) from an inline
|
|
44
|
+
* export declaration so it can be appended to the consolidated export list.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} exportNamedNode - An `ExportNamedDeclaration` node that
|
|
47
|
+
* has already been validated by {@link isInlineExportableDeclaration}.
|
|
48
|
+
* @returns {{ name: string, isType: boolean }} Export item describing the
|
|
49
|
+
* declared identifier and whether it should be marked `type` in the final
|
|
50
|
+
* export statement.
|
|
51
|
+
*/
|
|
52
|
+
function getExportItem(exportNamedNode) {
|
|
53
|
+
const { declaration } = exportNamedNode;
|
|
54
|
+
return {
|
|
55
|
+
name: declaration.id.name,
|
|
56
|
+
isType: TYPE_ONLY_DECLARATION_TYPES.has(declaration.type),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads the items from an existing specifier-only export statement so they
|
|
62
|
+
* can be merged with the items we are about to append.
|
|
63
|
+
*
|
|
64
|
+
* Handles both forms of type-only exports:
|
|
65
|
+
* - Statement-level: `export type { Foo, Bar }` → `exportNode.exportKind === 'type'`
|
|
66
|
+
* - Per-specifier: `export { type Foo, bar }` → `specifier.exportKind === 'type'`
|
|
67
|
+
*
|
|
68
|
+
* @param {object} exportNode - An `ExportNamedDeclaration` with no
|
|
69
|
+
* `declaration` and no `source` (i.e. a bare `export { … }`).
|
|
70
|
+
* @returns {Array<{ name: string, isType: boolean }>} The specifier list
|
|
71
|
+
* normalized into the same shape returned by {@link getExportItem}.
|
|
72
|
+
*/
|
|
73
|
+
function getSpecifierOnlyExportItems(exportNode) {
|
|
74
|
+
const items = [];
|
|
75
|
+
for (const specifier of exportNode.specifiers) {
|
|
76
|
+
// Skip `export { x as default }` (handled by ExportSpecifier with non-Identifier
|
|
77
|
+
// exported, e.g. StringLiteral) and any non-ExportSpecifier nodes a parser
|
|
78
|
+
// might produce.
|
|
79
|
+
if (specifier.type !== 'ExportSpecifier' || specifier.exported.type !== 'Identifier') {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const isType =
|
|
83
|
+
specifier.exportKind === 'type' ||
|
|
84
|
+
(exportNode.exportKind === 'type' && !exportNode.declaration);
|
|
85
|
+
items.push({ name: specifier.exported.name, isType });
|
|
86
|
+
}
|
|
87
|
+
return items;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Locates an existing bare `export { … }` / `export type { … }` statement at
|
|
92
|
+
* the program level so the autofix can merge new names into it instead of
|
|
93
|
+
* appending a second export block.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} programNode - The top-level `Program` AST node.
|
|
96
|
+
* @returns {object | null} The matching `ExportNamedDeclaration`, or null
|
|
97
|
+
* when no specifier-only export exists.
|
|
98
|
+
*/
|
|
99
|
+
function findSpecifierOnlyExport(programNode) {
|
|
100
|
+
const results = programNode.body.find(statement => statement.type === 'ExportNamedDeclaration'
|
|
101
|
+
&& !statement.declaration && !statement.source && statement.specifiers.length > 0);
|
|
102
|
+
return results || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Combines existing export items with newly added ones, preserving order and
|
|
107
|
+
* dropping duplicates by name (existing wins, so an existing `type` marker
|
|
108
|
+
* isn't accidentally downgraded to a value export).
|
|
109
|
+
*
|
|
110
|
+
* @param {Array<{ name: string, isType: boolean }>} existingItems
|
|
111
|
+
* @param {Array<{ name: string, isType: boolean }>} addedItems
|
|
112
|
+
* @returns {Array<{ name: string, isType: boolean }>} Deduplicated, ordered
|
|
113
|
+
* list of export items.
|
|
114
|
+
*/
|
|
115
|
+
function mergeExportItems(existingItems, addedItems) {
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const merged = [];
|
|
118
|
+
for (const item of existingItems) {
|
|
119
|
+
if (seen.has(item.name)) continue;
|
|
120
|
+
seen.add(item.name);
|
|
121
|
+
merged.push(item);
|
|
122
|
+
}
|
|
123
|
+
for (const item of addedItems) {
|
|
124
|
+
if (seen.has(item.name)) continue;
|
|
125
|
+
seen.add(item.name);
|
|
126
|
+
merged.push(item);
|
|
127
|
+
}
|
|
128
|
+
return merged;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Renders a list of export items as a single `export { … }` statement. When
|
|
133
|
+
* every item is type-only the output uses the statement-level `export type
|
|
134
|
+
* { … }` form; otherwise mixed lists use per-name `type` markers so type
|
|
135
|
+
* symbols stay erasable under `verbatimModuleSyntax`/isolated modules.
|
|
136
|
+
*
|
|
137
|
+
* @param {Array<{ name: string, isType: boolean }>} items
|
|
138
|
+
* @returns {string} A single-line export statement (no trailing newline).
|
|
139
|
+
*/
|
|
140
|
+
function formatMergedSpecifierExport(items) {
|
|
141
|
+
if (items.length === 0) {
|
|
142
|
+
return 'export {}';
|
|
143
|
+
}
|
|
144
|
+
if (items.every((item) => item.isType)) {
|
|
145
|
+
return `export type { ${items.map((item) => item.name).join(', ')} }`;
|
|
146
|
+
}
|
|
147
|
+
return `export { ${items
|
|
148
|
+
.map((item) => (item.isType ? `type ${item.name}` : item.name))
|
|
149
|
+
.join(', ')} }`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const exportAtEndOfFileRule = {
|
|
153
|
+
meta: {
|
|
154
|
+
type: 'suggestion',
|
|
155
|
+
docs: {
|
|
156
|
+
description:
|
|
157
|
+
'Disallow inline export on function, class, type, or interface declarations; use a single export list at the end of the file',
|
|
158
|
+
recommended: false,
|
|
159
|
+
},
|
|
160
|
+
fixable: 'code',
|
|
161
|
+
schema: [],
|
|
162
|
+
},
|
|
163
|
+
create(context) {
|
|
164
|
+
const { sourceCode } = context;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
// Use `Program:exit` so we have the full body before deciding whether to
|
|
168
|
+
// merge into an existing trailing `export { … }`. We also attach the
|
|
169
|
+
// single, file-spanning autofix to the *last* violation only - ESLint
|
|
170
|
+
// applies fixes in document order, and reporting the same set of edits
|
|
171
|
+
// from every violation would race and produce overlapping fixes.
|
|
172
|
+
'Program:exit'(programNode) {
|
|
173
|
+
const violations = programNode.body.filter(isInlineExportableDeclaration);
|
|
174
|
+
if (violations.length === 0) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const addedExportItems = violations.map(getExportItem);
|
|
179
|
+
const lastViolation = violations[violations.length - 1];
|
|
180
|
+
|
|
181
|
+
violations.forEach((node) => {
|
|
182
|
+
context.report({
|
|
183
|
+
node,
|
|
184
|
+
message:
|
|
185
|
+
'Declare this without inline export and list it in a single export statement at the end of the file.',
|
|
186
|
+
...(node === lastViolation
|
|
187
|
+
? {
|
|
188
|
+
fix(fixer) {
|
|
189
|
+
// Strip the leading `export` from each violation by
|
|
190
|
+
// replacing the whole `ExportNamedDeclaration` node with
|
|
191
|
+
// the source text of its inner declaration.
|
|
192
|
+
const edits = violations.map((exportNode) =>
|
|
193
|
+
fixer.replaceText(exportNode, sourceCode.getText(exportNode.declaration))
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const existingExport = findSpecifierOnlyExport(programNode);
|
|
197
|
+
if (existingExport) {
|
|
198
|
+
const merged = mergeExportItems(
|
|
199
|
+
getSpecifierOnlyExportItems(existingExport),
|
|
200
|
+
addedExportItems
|
|
201
|
+
);
|
|
202
|
+
edits.push(
|
|
203
|
+
fixer.replaceText(existingExport, formatMergedSpecifierExport(merged))
|
|
204
|
+
);
|
|
205
|
+
} else {
|
|
206
|
+
// No bare export to merge into - append a fresh one
|
|
207
|
+
// after the program's last token. Ends with a newline to follow
|
|
208
|
+
// best practices.
|
|
209
|
+
const lastToken = sourceCode.getLastToken(programNode);
|
|
210
|
+
edits.push(
|
|
211
|
+
fixer.insertTextAfter(
|
|
212
|
+
lastToken,
|
|
213
|
+
`\n\n${formatMergedSpecifierExport(addedExportItems)}\n`
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return edits;
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
: {}),
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export default exportAtEndOfFileRule;
|
package/package.json
CHANGED
package/test/eslint.config.js
CHANGED
package/test/fixtures.ts
CHANGED
|
@@ -29,7 +29,7 @@ const typedReturn = (x: number): number => x // expect-error yenz/no-named-arrow
|
|
|
29
29
|
const asyncWithParams = async (x: number) => x // expect-error yenz/no-named-arrow-functions // fix: async function asyncWithParams(x: number) { return x; }
|
|
30
30
|
let lv = () => {} // expect-error yenz/no-named-arrow-functions // fix: function lv() {}
|
|
31
31
|
var vv = () => {} // expect-error yenz/no-named-arrow-functions // fix: function vv() {}
|
|
32
|
-
export const exported = () => {} // expect-error yenz/no-named-arrow-functions // fix:
|
|
32
|
+
export const exported = () => {} // expect-error yenz/no-named-arrow-functions // fix: function exported() {}
|
|
33
33
|
|
|
34
34
|
// Should pass:
|
|
35
35
|
const arr2 = [1, 2, 3].map(x => x)
|
|
@@ -39,3 +39,11 @@ class MyClass {
|
|
|
39
39
|
const obj2 = {
|
|
40
40
|
method: () => {}
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
export function exportFunction() { return 1; } // expect-error yenz/export-at-end-of-file // fix: function exportFunction() { return 1; }
|
|
44
|
+
|
|
45
|
+
export class ExportClass { method() { return 1; } } // expect-error yenz/export-at-end-of-file // fix: class ExportClass { method() { return 1; } }
|
|
46
|
+
|
|
47
|
+
export type FixtureExportType = 1 // expect-error yenz/export-at-end-of-file // fix: type FixtureExportType = 1
|
|
48
|
+
|
|
49
|
+
export interface FixtureExportIface { n: number } // expect-error yenz/export-at-end-of-file // fix: interface FixtureExportIface { n: number }
|