eslint-plugin-yenz 2.2.0 → 2.2.2

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,8 +9,11 @@ jobs:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
11
  - uses: actions/checkout@v4
12
+ # Before setup-node cache: yarn — see https://github.com/actions/setup-node/issues/1027
13
+ - run: corepack enable
12
14
  - uses: actions/setup-node@v6
13
15
  with:
14
16
  node-version: 24
15
- - run: yarn install --frozen-lockfile
17
+ cache: yarn
18
+ - run: yarn install --immutable
16
19
  - run: yarn test
package/.yarnrc.yml ADDED
@@ -0,0 +1 @@
1
+ nodeLinker: node-modules
package/README.md CHANGED
@@ -121,6 +121,42 @@ arr.filter((item) => item.active);
121
121
  class Foo { bar = () => {} }
122
122
  ```
123
123
 
124
+ ### `yenz/export-at-end-of-file`
125
+
126
+ Disallows inline exports on function, class, type alias, and interface declarations. Prefer declaring first, then exporting all symbols in one statement at the end of the file. Auto-fixable.
127
+
128
+ > **Note:** This rule is *not* enabled in the `recommended`. Enable it explicitly or use `all`.
129
+
130
+ **Bad:**
131
+
132
+ ```typescript
133
+ export function parseDate(input: string) {
134
+ return new Date(input);
135
+ }
136
+
137
+ export interface UserProfile {
138
+ id: string;
139
+ }
140
+
141
+ export type UserStatus = 'active' | 'inactive';
142
+ ```
143
+
144
+ **Good:**
145
+
146
+ ```typescript
147
+ function parseDate(input: string) {
148
+ return new Date(input);
149
+ }
150
+
151
+ interface UserProfile {
152
+ id: string;
153
+ }
154
+
155
+ type UserStatus = 'active' | 'inactive';
156
+
157
+ export { parseDate, type UserProfile, type UserStatus };
158
+ ```
159
+
124
160
  ## Preset Configurations
125
161
 
126
162
  - `**recommended**` - Enables `type-ordering` as error and `no-loops` as warning
package/index.js CHANGED
@@ -31,6 +31,7 @@ plugin.configs = {
31
31
  'yenz/type-ordering': 'error',
32
32
  'yenz/no-loops': 'error',
33
33
  'yenz/no-named-arrow-functions': 'error',
34
+ 'yenz/export-at-end-of-file': 'error',
34
35
  },
35
36
  },
36
37
  };
@@ -39,27 +39,49 @@ function isInlineExportableDeclaration(node) {
39
39
  return EXPORTABLE_DECLARATION_TYPES.has(declaration.type) && Boolean(declaration.id);
40
40
  }
41
41
 
42
+ /**
43
+ * Matches `export default function foo() {}`, `export default class Foo {}`, or
44
+ * TypeScript `export default interface …` / `export default type …` when the
45
+ * declaration is named. Anonymous `export default function () {}` is skipped
46
+ * because there is no local binding to list in a trailing specifier export.
47
+ *
48
+ * @param {object} node - Top-level program statement node.
49
+ * @returns {boolean}
50
+ */
51
+ function isInlineDefaultExportableDeclaration(node) {
52
+ if (node.type !== 'ExportDefaultDeclaration' || !node.declaration) {
53
+ return false;
54
+ }
55
+ const { declaration } = node;
56
+ if (!EXPORTABLE_DECLARATION_TYPES.has(declaration.type)) {
57
+ return false;
58
+ }
59
+ return Boolean(declaration.id);
60
+ }
61
+
42
62
  /**
43
63
  * Extracts the exported name (and whether it's type-only) from an inline
44
64
  * export declaration so it can be appended to the consolidated export list.
45
65
  *
46
66
  * @param {object} exportNamedNode - An `ExportNamedDeclaration` node that
47
67
  * 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.
68
+ * @returns {{ localName: string, exportedName: string, isType: boolean }}
69
+ * Export item describing the declared binding, its exported name, and
70
+ * whether it should be marked `type` in the final export statement.
51
71
  */
52
72
  function getExportItem(exportNamedNode) {
53
73
  const { declaration } = exportNamedNode;
74
+ const name = declaration.id.name;
54
75
  return {
55
- name: declaration.id.name,
76
+ localName: name,
77
+ exportedName: name,
56
78
  isType: TYPE_ONLY_DECLARATION_TYPES.has(declaration.type),
57
79
  };
58
80
  }
59
81
 
60
82
  /**
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.
83
+ * Reads named specifiers and an optional `… as default` from an existing
84
+ * specifier-only export so they can be merged with new items.
63
85
  *
64
86
  * Handles both forms of type-only exports:
65
87
  * - Statement-level: `export type { Foo, Bar }` → `exportNode.exportKind === 'type'`
@@ -67,24 +89,35 @@ function getExportItem(exportNamedNode) {
67
89
  *
68
90
  * @param {object} exportNode - An `ExportNamedDeclaration` with no
69
91
  * `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}.
92
+ * @returns {{
93
+ * namedItems: Array<{ localName: string, exportedName: string, isType: boolean }>,
94
+ * defaultLocalName: string | null
95
+ * }}
72
96
  */
73
- function getSpecifierOnlyExportItems(exportNode) {
74
- const items = [];
97
+ function parseSpecifierOnlyExport(exportNode) {
98
+ const namedItems = [];
99
+ let defaultLocalName = null;
75
100
  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
101
  if (specifier.type !== 'ExportSpecifier' || specifier.exported.type !== 'Identifier') {
80
102
  continue;
81
103
  }
104
+ if (specifier.local.type !== 'Identifier') {
105
+ continue;
106
+ }
107
+ if (specifier.exported.name === 'default') {
108
+ defaultLocalName = specifier.local.name;
109
+ continue;
110
+ }
82
111
  const isType =
83
112
  specifier.exportKind === 'type' ||
84
113
  (exportNode.exportKind === 'type' && !exportNode.declaration);
85
- items.push({ name: specifier.exported.name, isType });
114
+ namedItems.push({
115
+ localName: specifier.local.name,
116
+ exportedName: specifier.exported.name,
117
+ isType,
118
+ });
86
119
  }
87
- return items;
120
+ return { namedItems, defaultLocalName };
88
121
  }
89
122
 
90
123
  /**
@@ -107,61 +140,87 @@ function findSpecifierOnlyExport(programNode) {
107
140
  * dropping duplicates by name (existing wins, so an existing `type` marker
108
141
  * isn't accidentally downgraded to a value export).
109
142
  *
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.
143
+ * @param {Array<{ localName: string, exportedName: string, isType: boolean }>} existingItems
144
+ * @param {Array<{ localName: string, exportedName: string, isType: boolean }>} addedItems
145
+ * @returns {Array<{ localName: string, exportedName: string, isType: boolean }>}
146
+ * Deduplicated, ordered list of export items (keyed by exported name).
114
147
  */
115
148
  function mergeExportItems(existingItems, addedItems) {
116
149
  const seen = new Set();
117
150
  const merged = [];
118
151
  for (const item of existingItems) {
119
- if (seen.has(item.name)) continue;
120
- seen.add(item.name);
152
+ if (seen.has(item.exportedName)) continue;
153
+ seen.add(item.exportedName);
121
154
  merged.push(item);
122
155
  }
123
156
  for (const item of addedItems) {
124
- if (seen.has(item.name)) continue;
125
- seen.add(item.name);
157
+ if (seen.has(item.exportedName)) continue;
158
+ seen.add(item.exportedName);
126
159
  merged.push(item);
127
160
  }
128
161
  return merged;
129
162
  }
130
163
 
131
164
  /**
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.
165
+ * @param {string | null} existingDefault
166
+ * @param {string | null} addedDefault
167
+ * @returns {string | null}
168
+ */
169
+ function mergeDefaultExportLocal(existingDefault, addedDefault) {
170
+ if (addedDefault !== null) {
171
+ return addedDefault;
172
+ }
173
+ return existingDefault;
174
+ }
175
+
176
+ /** @param {{ localName: string, exportedName: string }} item */
177
+ function formatNamedSpecifierEntry(item) {
178
+ if (item.localName === item.exportedName) {
179
+ return item.localName;
180
+ }
181
+ return `${item.localName} as ${item.exportedName}`;
182
+ }
183
+
184
+ /**
185
+ * Renders named specifiers and an optional default as one export statement.
186
+ * When every named item is type-only and there is no default, uses
187
+ * `export type { … }`; otherwise uses `export { … }` with per-name `type`
188
+ * markers where needed.
136
189
  *
137
- * @param {Array<{ name: string, isType: boolean }>} items
190
+ * @param {Array<{ localName: string, exportedName: string, isType: boolean }>} namedItems
191
+ * @param {string | null} defaultLocalName - Local binding listed as `name as default`.
138
192
  * @returns {string} A single-line export statement (no trailing newline).
139
193
  */
140
- function formatMergedSpecifierExport(items) {
141
- if (items.length === 0) {
194
+ function formatMergedSpecifierExport(namedItems, defaultLocalName) {
195
+ const parts = namedItems.map((item) => {
196
+ const entry = formatNamedSpecifierEntry(item);
197
+ return item.isType ? `type ${entry}` : entry;
198
+ });
199
+ if (defaultLocalName !== null) {
200
+ parts.push(`${defaultLocalName} as default`);
201
+ }
202
+ if (parts.length === 0) {
142
203
  return 'export {}';
143
204
  }
144
- if (items.every((item) => item.isType)) {
145
- return `export type { ${items.map((item) => item.name).join(', ')} }`;
205
+ const canUseExportTypeStatement =
206
+ defaultLocalName === null && namedItems.length > 0 && namedItems.every((item) => item.isType);
207
+ if (canUseExportTypeStatement) {
208
+ return `export type { ${namedItems.map((item) => formatNamedSpecifierEntry(item)).join(', ')} }`;
146
209
  }
147
- return `export { ${items
148
- .map((item) => (item.isType ? `type ${item.name}` : item.name))
149
- .join(', ')} }`;
210
+ return `export { ${parts.join(', ')} }`;
150
211
  }
151
212
 
152
213
  /**
153
- * Returns a location that covers only the exported header (for example
154
- * `export function foo()` through the closing `)`), not the whole declaration
155
- * body, so editors underline the signature instead of the entire block.
214
+ * Returns a location that covers only the declaration header (through `(` or
215
+ * `{` before the body, or through `=` for type aliases), not the whole block.
156
216
  *
157
- * @param {object} exportNamedNode - Inline `ExportNamedDeclaration`.
217
+ * @param {object} declaration - Function, class, interface, or type alias node.
218
+ * @param {object} reportStart - `loc.start` of the export statement.
219
+ * @param {object} endFallbackLoc - Full span if no tighter range applies.
158
220
  * @param {import('eslint').SourceCode} sourceCode
159
221
  * @returns {object} ESLint `SourceLocation` (`start` / `end` in line/column).
160
222
  */
161
- function getInlineExportReportLoc(exportNamedNode, sourceCode) {
162
- const declaration = exportNamedNode.declaration;
163
- const { start } = exportNamedNode.loc;
164
-
223
+ function getDeclarationSignatureLoc(declaration, reportStart, endFallbackLoc, sourceCode) {
165
224
  const endsBeforeBraceBody =
166
225
  (declaration.type === 'FunctionDeclaration'
167
226
  || declaration.type === 'ClassDeclaration'
@@ -171,7 +230,7 @@ function getInlineExportReportLoc(exportNamedNode, sourceCode) {
171
230
  if (endsBeforeBraceBody) {
172
231
  const bodyOpenIndex = declaration.body.range[0];
173
232
  return {
174
- start,
233
+ start: reportStart,
175
234
  end: sourceCode.getLocFromIndex(bodyOpenIndex),
176
235
  };
177
236
  }
@@ -183,13 +242,25 @@ function getInlineExportReportLoc(exportNamedNode, sourceCode) {
183
242
  });
184
243
  if (equalsToken) {
185
244
  return {
186
- start,
245
+ start: reportStart,
187
246
  end: sourceCode.getLocFromIndex(equalsToken.range[0]),
188
247
  };
189
248
  }
190
249
  }
191
250
 
192
- return exportNamedNode.loc;
251
+ return endFallbackLoc;
252
+ }
253
+
254
+ function getInlineExportReportLoc(exportNamedNode, sourceCode) {
255
+ const declaration = exportNamedNode.declaration;
256
+ const { start } = exportNamedNode.loc;
257
+ return getDeclarationSignatureLoc(declaration, start, exportNamedNode.loc, sourceCode);
258
+ }
259
+
260
+ function getInlineDefaultExportReportLoc(exportDefaultNode, sourceCode) {
261
+ const declaration = exportDefaultNode.declaration;
262
+ const { start } = exportDefaultNode.loc;
263
+ return getDeclarationSignatureLoc(declaration, start, exportDefaultNode.loc, sourceCode);
193
264
  }
194
265
 
195
266
  const exportAtEndOfFileRule = {
@@ -197,7 +268,7 @@ const exportAtEndOfFileRule = {
197
268
  type: 'suggestion',
198
269
  docs: {
199
270
  description:
200
- 'Disallow inline export on function, class, type, or interface declarations; use a single export list at the end of the file',
271
+ 'Disallow inline export (including default export) on function, class, type, or interface declarations; use export specifiers at the end of the file',
201
272
  recommended: false,
202
273
  },
203
274
  fixable: 'code',
@@ -213,47 +284,63 @@ const exportAtEndOfFileRule = {
213
284
  // applies fixes in document order, and reporting the same set of edits
214
285
  // from every violation would race and produce overlapping fixes.
215
286
  'Program:exit'(programNode) {
216
- const violations = programNode.body.filter(isInlineExportableDeclaration);
287
+ const violations = programNode.body.filter(
288
+ (statement) =>
289
+ isInlineExportableDeclaration(statement)
290
+ || isInlineDefaultExportableDeclaration(statement)
291
+ );
217
292
  if (violations.length === 0) {
218
293
  return;
219
294
  }
220
295
 
221
- const addedExportItems = violations.map(getExportItem);
296
+ const addedExportItems = violations
297
+ .filter((node) => node.type === 'ExportNamedDeclaration')
298
+ .map(getExportItem);
299
+
300
+ let addedDefaultLocal = null;
301
+ for (const node of violations) {
302
+ if (node.type === 'ExportDefaultDeclaration' && node.declaration?.id) {
303
+ addedDefaultLocal = node.declaration.id.name;
304
+ }
305
+ }
306
+
222
307
  const lastViolation = violations[violations.length - 1];
223
308
 
224
309
  violations.forEach((node) => {
310
+ const reportLoc =
311
+ node.type === 'ExportDefaultDeclaration'
312
+ ? getInlineDefaultExportReportLoc(node, sourceCode)
313
+ : getInlineExportReportLoc(node, sourceCode);
314
+
225
315
  context.report({
226
- loc: getInlineExportReportLoc(node, sourceCode),
316
+ loc: reportLoc,
227
317
  message:
228
318
  'Declare this without inline export and list it in a single export statement at the end of the file.',
229
319
  ...(node === lastViolation
230
320
  ? {
231
321
  fix(fixer) {
232
- // Strip the leading `export` from each violation by
233
- // replacing the whole `ExportNamedDeclaration` node with
234
- // the source text of its inner declaration.
235
322
  const edits = violations.map((exportNode) =>
236
323
  fixer.replaceText(exportNode, sourceCode.getText(exportNode.declaration))
237
324
  );
238
325
 
239
326
  const existingExport = findSpecifierOnlyExport(programNode);
240
327
  if (existingExport) {
241
- const merged = mergeExportItems(
242
- getSpecifierOnlyExportItems(existingExport),
243
- addedExportItems
244
- );
328
+ const { namedItems: existingNamed, defaultLocalName: existingDefault } =
329
+ parseSpecifierOnlyExport(existingExport);
330
+ const mergedNamed = mergeExportItems(existingNamed, addedExportItems);
331
+ const mergedDefault = mergeDefaultExportLocal(existingDefault, addedDefaultLocal);
245
332
  edits.push(
246
- fixer.replaceText(existingExport, formatMergedSpecifierExport(merged))
333
+ fixer.replaceText(
334
+ existingExport,
335
+ formatMergedSpecifierExport(mergedNamed, mergedDefault)
336
+ )
247
337
  );
248
338
  } else {
249
- // No bare export to merge into - append a fresh one
250
- // after the program's last token. Ends with a newline to follow
251
- // best practices.
252
339
  const lastToken = sourceCode.getLastToken(programNode);
253
340
  edits.push(
254
341
  fixer.insertTextAfter(
255
342
  lastToken,
256
- `\n\n${formatMergedSpecifierExport(addedExportItems)}\n`
343
+ `\n\n${formatMergedSpecifierExport(addedExportItems, addedDefaultLocal)}\n`
257
344
  )
258
345
  );
259
346
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "eslint-plugin-yenz",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
+ "packageManager": "yarn@4.10.2",
4
5
  "description": "Adds custom rules that Jens likes",
5
6
  "repository": "https://github.com/JensAstrup/eslint-plugin-yenz",
6
7
  "type": "module",
package/test/fixtures.ts CHANGED
@@ -48,8 +48,10 @@ export type FixtureExportType = 1 // expect-error yenz/export-at-end-of-file //
48
48
 
49
49
  export interface FixtureExportIface { n: number } // expect-error yenz/export-at-end-of-file // fix: interface FixtureExportIface { n: number }
50
50
 
51
+ export default function defaultExport() { return 1; } // expect-error yenz/export-at-end-of-file // fix: function defaultExport() { return 1; }
52
+
51
53
  // Should pass (export-at-end-of-file — specifier exports and non-exported decls only):
52
54
  function notExported() {}
53
55
  function someExisting() {}
54
- export { someExisting }
56
+ export { someExisting as someExistingPublic }
55
57
  export { foo } from './other'