Files
LibreChat/scripts/sort-imports.mts
Danny Avila bfb6b224d2 🔧 chore: Update ESLint config, Import Sorting script, Test Sharding, Bump @librechat/agents (#13552)
* 🔧 chore: Update ESLint config, add import sorting script, Test Sharding, Bump `@librechat/agents`

* Change 'no-nested-ternary' rule from 'warn' to 'error' in ESLint config
* Add new scripts for sorting imports in the project
* Update lint-staged configuration to include import sorting
* Modify GitHub Actions workflows to support sharding for unit tests

* chore: remove nested ternary expressions

* refactor: Extract scale multiplier logic into a separate function in CircleRender component
* refactor: Simplify auto-refill rendering logic in Balance component for better readability
* refactor: Improve width style handling in DataTable components for clarity and maintainability

* chore: remove CircleRender component

* delete: Remove CircleRender component as it is no longer needed in the project

* chore: Bump @librechat/agents to version 3.2.31 and update Node.js engine requirement

* Update @librechat/agents dependency from 3.2.2 to 3.2.31 in package-lock.json, api/package.json, and packages/api/package.json
* Change Node.js engine requirement from >=20.0.0 to >=24.0.0 in @librechat/agents

* chore: Add import sorting check to ESLint CI workflow

* Implement a new job in the GitHub Actions workflow to verify import ordering on changed files.
* The job checks for changes in specific file types and reports any import order drift, providing instructions for local fixes.
2026-06-06 12:31:55 -04:00

258 lines
7.1 KiB
TypeScript

#!/usr/bin/env node
/**
* Sorts imports across the LibreChat monorepo per project convention
* (CLAUDE.md § Import Order):
*
* 1. Package value imports — shortest line to longest (`react` always first)
* 2. import type from packages — longest line to shortest
* 3. import type from local — longest line to shortest
* 4. Local value imports — longest line to shortest
*
* "Local" covers relative paths (`./`, `../`) and the workspace path aliases
* (`~/`, `src/`, `test/`). Workspace packages such as `librechat-data-provider`
* and `@librechat/*` are treated as package imports, not local.
*
* Runs on Node 24+ via native type-stripping (`.mts` keeps ESM semantics under
* the CommonJS repo root):
*
* Run: npm run sort-imports
* Check only: npm run sort-imports:check
* Targeted: node scripts/sort-imports.mts path/to/file.ts [...]
*/
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { join, relative, resolve, sep, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** Source roots scanned when no explicit files are passed. */
const SOURCE_ROOTS = [
'api',
'client/src',
'packages/api/src',
'packages/data-provider/src',
'packages/data-schemas/src',
'packages/client/src',
];
const SOURCE_DIRS = SOURCE_ROOTS.map((rel) => resolve(ROOT, rel));
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'];
const SKIP_DIR_NAMES = new Set([
'node_modules',
'dist',
'types',
'coverage',
'.turbo',
'data',
'demo',
]);
const args = process.argv.slice(2);
const CHECK = args.includes('--check');
const FILE_ARGS = args.filter((arg) => !arg.startsWith('--'));
const LOCAL_PREFIXES = ['~/', 'src/', 'test/', './', '../'];
/** Per-file opt-out for modules where import order is load-bearing. */
const IGNORE_MARKER = /^\s*\/\/\s*sort-imports-ignore\b/;
function isLocal(spec: string): boolean {
return LOCAL_PREFIXES.some((prefix) => spec.startsWith(prefix));
}
function hasSourceExtension(path: string): boolean {
return EXTENSIONS.some((ext) => path.endsWith(ext));
}
function isUnderSourceDir(abs: string): boolean {
return SOURCE_DIRS.some((dir) => abs === dir || abs.startsWith(`${dir}${sep}`));
}
interface Stmt {
raw: string;
spec: string;
isType: boolean;
isLocal: boolean;
len: number;
}
function extractSpec(raw: string): string | null {
return raw.match(/from\s+['"]([^'"]+)['"]/)?.[1] ?? null;
}
/** Applies the CLAUDE.md grouping/length ordering to a run of pure imports. */
function sortSegment(stmts: Stmt[]): string[] {
const g1 = stmts
.filter((s) => !s.isType && !s.isLocal)
.sort((a, b) => {
const aReact = a.spec === 'react' ? 0 : 1;
const bReact = b.spec === 'react' ? 0 : 1;
if (aReact !== bReact) return aReact - bReact;
return a.len - b.len;
});
const g2 = stmts
.filter((s) => s.isType && !s.isLocal)
.sort((a, b) => b.len - a.len);
const g3 = stmts
.filter((s) => s.isType && s.isLocal)
.sort((a, b) => b.len - a.len);
const g4 = stmts
.filter((s) => !s.isType && s.isLocal)
.sort((a, b) => b.len - a.len);
return [...g1, ...g2, ...g3, ...g4].map((s) => s.raw);
}
function sortFileImports(content: string): string | null {
const lines = content.split('\n');
if (lines.some((line) => IGNORE_MARKER.test(line))) {
return null;
}
let i = 0;
while (i < lines.length) {
const t = lines[i].trimStart();
if (
t === '' ||
t.startsWith('//') ||
t.startsWith('/*') ||
t.startsWith('*') ||
t.startsWith('*/') ||
t.startsWith('\'use ') ||
t.startsWith('"use ')
) {
i++;
} else {
break;
}
}
const importStart = i;
// Side-effect imports (no `from` clause) are treated as immovable barriers:
// sorting is confined to each contiguous run of pure imports between them, so
// module-evaluation order around anything with side effects (polyfills,
// registration, css, etc.) is never changed.
const emitted: string[] = [];
const originalRaws: string[] = [];
let segment: Stmt[] = [];
let importEnd = i;
const flushSegment = (): void => {
if (segment.length === 0) return;
emitted.push(...sortSegment(segment));
segment = [];
};
while (i < lines.length) {
const t = lines[i].trimStart();
if (!t.startsWith('import ') && !t.startsWith('import{')) break;
let raw = lines[i];
let j = i;
while (!raw.includes(';') && j + 1 < lines.length) {
j++;
raw += '\n' + lines[j];
}
i = j + 1;
importEnd = i;
originalRaws.push(raw);
const spec = extractSpec(raw);
if (spec == null || spec === '') {
flushSegment();
emitted.push(raw);
while (i < lines.length && lines[i].trim() === '') i++;
continue;
}
segment.push({
raw,
spec,
isType: /^import\s+type[\s{]/.test(raw.trimStart()),
isLocal: isLocal(spec),
len: raw
.split('\n')
.map((l) => l.trim())
.join(' ').length,
});
while (i < lines.length && lines[i].trim() === '') i++;
}
flushSegment();
if (originalRaws.length < 2) return null;
if (originalRaws.join('\n') === emitted.join('\n')) return null;
return [
...lines.slice(0, importStart),
...emitted,
...lines.slice(importEnd),
].join('\n');
}
/** Recursively yields absolute paths of every source file under `dir`. */
async function* walkSourceFiles(dir: string): AsyncGenerator<string> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIR_NAMES.has(entry.name)) continue;
yield* walkSourceFiles(join(dir, entry.name));
} else if (entry.isFile() && hasSourceExtension(entry.name)) {
yield join(dir, entry.name);
}
}
}
/**
* Resolves the set of files to process. When explicit paths are passed
* (e.g. by lint-staged) only those source files under a known root are sorted;
* otherwise every source file under each root is scanned.
*/
async function collectFiles(): Promise<string[]> {
if (FILE_ARGS.length > 0) {
return FILE_ARGS.map((file) => resolve(file)).filter(
(abs) => hasSourceExtension(abs) && isUnderSourceDir(abs),
);
}
const files: string[] = [];
for (const dir of SOURCE_DIRS) {
try {
for await (const abs of walkSourceFiles(dir)) {
files.push(abs);
}
} catch {
continue;
}
}
return files;
}
let changed = 0;
let total = 0;
for (const filePath of await collectFiles()) {
const rel = relative(ROOT, filePath);
const content = await readFile(filePath, 'utf8');
const result = sortFileImports(content);
total++;
if (result === null) continue;
changed++;
if (CHECK) {
console.log(`${rel}`);
} else {
await writeFile(filePath, result);
console.log(`${rel}`);
}
}
if (CHECK && changed) {
console.log(`\n${changed}/${total} files need sorting. Run: npm run sort-imports`);
process.exit(1);
} else if (changed) {
console.log(`\nSorted ${changed}/${total} files.`);
} else {
console.log(`All ${total} files already sorted.`);
}