eslint-plugin-yenz 2.1.1-beta.0 → 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.
@@ -9,7 +9,7 @@ jobs:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
11
  - uses: actions/checkout@v4
12
- - uses: actions/setup-node@v4
12
+ - uses: actions/setup-node@v6
13
13
  with:
14
14
  node-version: 24
15
15
  - run: yarn install --frozen-lockfile
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 _not_ enabled in the `recommended` preset. Enable it explicitly or use the `all` preset.
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 _not_ enabled in the `recommended` preset. Enable it explicitly or use the `all` preset.
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
- - **`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
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
- ```bash
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
- 8. **Publish the stable release** from `main`:
148
- ```bash
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-yenz",
3
- "version": "2.1.1-beta.0",
3
+ "version": "2.2.0-beta.1",
4
4
  "description": "Adds custom rules that Jens likes",
5
5
  "repository": "https://github.com/JensAstrup/eslint-plugin-yenz",
6
6
  "type": "module",
@@ -29,6 +29,6 @@
29
29
  "typescript-eslint": ">=8.0.0"
30
30
  },
31
31
  "engines": {
32
- "node": ">=14.0.0"
32
+ "node": ">=20.19.0 "
33
33
  }
34
34
  }
@@ -14,6 +14,7 @@ export default [
14
14
  'yenz/type-ordering': 'error',
15
15
  'yenz/no-loops': 'error',
16
16
  'yenz/no-named-arrow-functions': 'error',
17
+ 'yenz/export-at-end-of-file': 'error',
17
18
  },
18
19
  },
19
20
  ];
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: export function exported() {}
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 }