Skip to content

Instantly share code, notes, and snippets.

@Yorkshireman
Created June 10, 2026 15:27
Show Gist options
  • Select an option

  • Save Yorkshireman/f4f8b930bc9fb5ae3ed28d8cd4cdaa87 to your computer and use it in GitHub Desktop.

Select an option

Save Yorkshireman/f4f8b930bc9fb5ae3ed28d8cd4cdaa87 to your computer and use it in GitHub Desktop.
Eslint member-syntax-order script to auto-fix
#!/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