Created
June 10, 2026 15:27
-
-
Save Yorkshireman/f4f8b930bc9fb5ae3ed28d8cd4cdaa87 to your computer and use it in GitHub Desktop.
Eslint member-syntax-order script to auto-fix
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| // run with (for example): node fix-import-member-syntax-order.mjs apps/my-app/src | |
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import * as parser from '@typescript-eslint/parser'; | |
| const MEMBER_SYNTAX_ORDER = ['none', 'all', 'single', 'multiple']; | |
| const EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx']); | |
| const IGNORED_DIRECTORIES = new Set([ | |
| '.git', | |
| 'coverage', | |
| 'dist', | |
| 'node_modules', | |
| 'playwright-report' | |
| ]); | |
| const args = process.argv.slice(2); | |
| const check = args.includes('--check'); | |
| const roots = args.filter((arg) => arg !== '--check'); | |
| const targets = roots.length ? roots : ['apps/admin/src']; | |
| let changedFiles = 0; | |
| let checkedFiles = 0; | |
| let skippedFiles = 0; | |
| function main() { | |
| for (const target of targets) { | |
| const absoluteTarget = path.resolve(target); | |
| if (!fs.existsSync(absoluteTarget)) { | |
| console.error(`Path does not exist: ${target}`); | |
| process.exitCode = 1; | |
| continue; | |
| } | |
| for (const filePath of collectFiles(absoluteTarget)) { | |
| processFile(filePath); | |
| } | |
| } | |
| const mode = check ? 'would change' : 'changed'; | |
| console.log( | |
| `${mode}: ${changedFiles}, checked: ${checkedFiles}, skipped: ${skippedFiles}` | |
| ); | |
| if (check && changedFiles > 0) { | |
| process.exitCode = 1; | |
| } | |
| } | |
| function* collectFiles(target) { | |
| const stat = fs.statSync(target); | |
| if (stat.isFile()) { | |
| if (EXTENSIONS.has(path.extname(target))) { | |
| yield target; | |
| } | |
| return; | |
| } | |
| if (!stat.isDirectory()) { | |
| return; | |
| } | |
| for (const entry of fs.readdirSync(target, { withFileTypes: true })) { | |
| if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) { | |
| continue; | |
| } | |
| yield* collectFiles(path.join(target, entry.name)); | |
| } | |
| } | |
| function processFile(filePath) { | |
| checkedFiles += 1; | |
| const source = fs.readFileSync(filePath, 'utf8'); | |
| let ast; | |
| try { | |
| ast = parser.parse(source, { | |
| comment: true, | |
| ecmaFeatures: { jsx: true }, | |
| ecmaVersion: 'latest', | |
| loc: true, | |
| range: true, | |
| sourceType: 'module', | |
| tokens: true | |
| }); | |
| } catch (error) { | |
| skippedFiles += 1; | |
| console.warn(`Skipped ${path.relative(process.cwd(), filePath)}: ${error.message}`); | |
| return; | |
| } | |
| const imports = ast.body.filter((node) => node.type === 'ImportDeclaration'); | |
| if (imports.length < 2) { | |
| return; | |
| } | |
| const lineStarts = getLineStarts(source); | |
| const edits = getImportBlocks(imports).flatMap((block) => | |
| getSortedBlockEdit(source, lineStarts, block) | |
| ); | |
| if (edits.length === 0) { | |
| return; | |
| } | |
| changedFiles += 1; | |
| if (check) { | |
| console.log(path.relative(process.cwd(), filePath)); | |
| return; | |
| } | |
| fs.writeFileSync(filePath, applyEdits(source, edits)); | |
| } | |
| function getImportBlocks(imports) { | |
| const blocks = []; | |
| let currentBlock = [imports[0]]; | |
| for (const node of imports.slice(1)) { | |
| const previous = currentBlock.at(-1); | |
| if (node.loc.start.line === previous.loc.end.line + 1) { | |
| currentBlock.push(node); | |
| } else { | |
| blocks.push(currentBlock); | |
| currentBlock = [node]; | |
| } | |
| } | |
| blocks.push(currentBlock); | |
| return blocks; | |
| } | |
| function getSortedBlockEdit(source, lineStarts, block) { | |
| const imports = block.map((node, index) => ({ | |
| index, | |
| node, | |
| rank: MEMBER_SYNTAX_ORDER.indexOf(getMemberSyntax(node)), | |
| sortName: getFirstLocalMemberName(node)?.toLowerCase() ?? null, | |
| text: source.slice( | |
| getLineStartOffset(lineStarts, node.loc.start.line), | |
| getLineEndOffset(source, lineStarts, node.loc.end.line) | |
| ) | |
| })); | |
| const sortedImports = [...imports].sort(compareImports); | |
| const changed = sortedImports.some((item, index) => item.index !== index); | |
| if (!changed) { | |
| return []; | |
| } | |
| const start = getLineStartOffset(lineStarts, block[0].loc.start.line); | |
| const end = getLineEndOffset(source, lineStarts, block.at(-1).loc.end.line); | |
| const replacement = sortedImports.map((item) => item.text).join('\n'); | |
| return [{ end, replacement, start }]; | |
| } | |
| function compareImports(left, right) { | |
| if (left.rank !== right.rank) { | |
| return left.rank - right.rank; | |
| } | |
| if (left.sortName && right.sortName && left.sortName !== right.sortName) { | |
| return left.sortName < right.sortName ? -1 : 1; | |
| } | |
| return left.index - right.index; | |
| } | |
| function getMemberSyntax(node) { | |
| if (node.specifiers.length === 0) { | |
| return 'none'; | |
| } | |
| if (node.specifiers[0].type === 'ImportNamespaceSpecifier') { | |
| return 'all'; | |
| } | |
| if (node.specifiers.length === 1) { | |
| return 'single'; | |
| } | |
| return 'multiple'; | |
| } | |
| function getFirstLocalMemberName(node) { | |
| return node.specifiers[0]?.local?.name ?? null; | |
| } | |
| function getLineStarts(source) { | |
| const starts = [0]; | |
| for (let index = 0; index < source.length; index += 1) { | |
| if (source[index] === '\n') { | |
| starts.push(index + 1); | |
| } | |
| } | |
| return starts; | |
| } | |
| function getLineStartOffset(lineStarts, line) { | |
| return lineStarts[line - 1]; | |
| } | |
| function getLineEndOffset(source, lineStarts, line) { | |
| const nextLineStart = lineStarts[line]; | |
| if (nextLineStart === undefined) { | |
| return source.length; | |
| } | |
| return source[nextLineStart - 1] === '\n' ? nextLineStart - 1 : nextLineStart; | |
| } | |
| function applyEdits(source, edits) { | |
| return [...edits] | |
| .sort((left, right) => right.start - left.start) | |
| .reduce( | |
| (updatedSource, edit) => | |
| `${updatedSource.slice(0, edit.start)}${edit.replacement}${updatedSource.slice(edit.end)}`, | |
| source | |
| ); | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment