🔧 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.
This commit is contained in:
Danny Avila 2026-06-06 12:31:55 -04:00 committed by GitHub
parent 21607ba3d7
commit bfb6b224d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 396 additions and 114 deletions

View file

@ -215,10 +215,14 @@ jobs:
fi
test-api:
name: 'Tests: api'
name: 'Tests: api (shard ${{ matrix.shard }}/3)'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
env:
MONGO_URI: ${{ secrets.MONGO_URI }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@ -278,8 +282,8 @@ jobs:
- name: Prepare .env.test file
run: cp api/test/.env.test.example api/test/.env.test
- name: Run unit tests
run: cd api && npm run test:ci
- name: Run unit tests (shard ${{ matrix.shard }}/3)
run: cd api && npm run test:ci -- --shard=${{ matrix.shard }}/3
test-data-provider:
name: 'Tests: data-provider'
@ -364,14 +368,18 @@ jobs:
run: cd packages/data-schemas && npm run test:ci
test-packages-api:
name: 'Tests: @librechat/api'
name: 'Tests: @librechat/api (shard ${{ matrix.shard }}/4)'
needs: build
runs-on: ubuntu-latest
# Suite typically completes in ~5 min on a warm runner, but tail-latency
# cancellations have started showing up: tests are actively passing right
# up to the timeout, then the job is killed mid-suite. Bump headroom to
# absorb GitHub Actions runner variance.
# up to the timeout, then the job is killed mid-suite. Sharding splits the
# suite across runners; per-shard headroom still absorbs runner variance.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
@ -414,5 +422,5 @@ jobs:
name: build-api
path: packages/api/dist
- name: Run unit tests
run: cd packages/api && npm run test:ci
- name: Run unit tests (shard ${{ matrix.shard }}/4)
run: cd packages/api && npm run test:ci -- --shard=${{ matrix.shard }}/4

View file

@ -94,3 +94,31 @@ jobs:
echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
exit 1
fi
# Verify import ordering on the same set of changed files. The script
# only sorts files under known source roots, so unrelated changed files
# (configs, etc.) are ignored. Matches the lint-staged pre-commit hook.
- name: Check import sorting on changed files
run: |
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
mapfile -d '' -t CHANGED_FILES < <(
git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
)
if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
echo "No matching files changed. Skipping import-sort check."
exit 0
fi
echo "Files to check:"
printf '%s\n' "${CHANGED_FILES[@]}"
# `--check` lists offending files and exits non-zero without writing.
if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then
echo ""
echo "::error::Import order drift detected. Fix locally with:"
echo "::error:: npm run sort-imports"
echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
exit 1
fi

View file

@ -77,10 +77,14 @@ jobs:
retention-days: 2
test-ubuntu:
name: 'Tests: Ubuntu'
name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/4)'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
@ -116,8 +120,8 @@ jobs:
name: build-client-package
path: packages/client/dist
- name: Run unit tests
run: npm run test:ci --verbose
- name: Run unit tests (shard ${{ matrix.shard }}/4)
run: npm run test:ci -- --shard=${{ matrix.shard }}/4
working-directory: client
test-windows:

View file

@ -1,4 +1,9 @@
module.exports = {
'*.{js,jsx,ts,tsx}': ['prettier --write', 'eslint --fix', 'eslint'],
'*.{js,jsx,ts,tsx}': [
'node scripts/sort-imports.mts',
'prettier --write',
'eslint --fix',
'eslint',
],
'*.json': ['prettier --write'],
};

View file

@ -46,7 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.0.1",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.2.2",
"@librechat/agents": "^3.2.31",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View file

@ -1,36 +0,0 @@
import React from 'react';
import { ECallState } from 'librechat-data-provider';
import { CircleIcon, CircleDotsIcon } from '@librechat/client';
const CircleRender = ({ rmsLevel, isCameraOn, state }) => {
const getIconComponent = (state) => {
switch (state) {
case ECallState.Thinking:
return <CircleDotsIcon />;
default:
return (
<div className="smooth-transition" style={{ transform: `scale(${transformScale})` }}>
<CircleIcon state={state} size="256" />
</div>
);
}
};
const baseScale = isCameraOn ? 0.5 : 1;
const scaleMultiplier =
rmsLevel > 0.08
? 1.8
: rmsLevel > 0.07
? 1.6
: rmsLevel > 0.05
? 1.4
: rmsLevel > 0.01
? 1.2
: 1;
const transformScale = baseScale * scaleMultiplier;
return getIconComponent(state);
};
export default CircleRender;

View file

@ -1,8 +1,8 @@
import React from 'react';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import { useAuthContext, useLocalize } from '~/hooks';
import TokenCreditsItem from './TokenCreditsItem';
import AutoRefillSettings from './AutoRefillSettings';
import TokenCreditsItem from './TokenCreditsItem';
function Balance() {
const localize = useLocalize();
@ -31,30 +31,36 @@ function Balance() {
refillIntervalUnit !== undefined &&
refillIntervalValue !== undefined;
const renderAutoRefill = () => {
if (!autoRefillEnabled) {
return (
<div className="text-sm text-gray-600">
{localize('com_nav_balance_auto_refill_disabled')}
</div>
);
}
if (!hasValidRefillSettings) {
return (
<div className="text-sm text-red-600">{localize('com_nav_balance_auto_refill_error')}</div>
);
}
return (
<AutoRefillSettings
lastRefill={lastRefill}
refillAmount={refillAmount}
refillIntervalUnit={refillIntervalUnit}
refillIntervalValue={refillIntervalValue}
/>
);
};
return (
<div className="flex flex-col gap-4 p-4 text-sm text-text-primary">
{/* Token credits display */}
<TokenCreditsItem tokenCredits={tokenCredits} />
{/* Auto-refill display */}
{autoRefillEnabled ? (
hasValidRefillSettings ? (
<AutoRefillSettings
lastRefill={lastRefill}
refillAmount={refillAmount}
refillIntervalUnit={refillIntervalUnit}
refillIntervalValue={refillIntervalValue}
/>
) : (
<div className="text-sm text-red-600">
{localize('com_nav_balance_auto_refill_error')}
</div>
)
) : (
<div className="text-sm text-gray-600">
{localize('com_nav_balance_auto_refill_disabled')}
</div>
)}
{renderAutoRefill()}
</div>
);
}

View file

@ -120,7 +120,7 @@ export default [
'jsx-a11y/img-redundant-alt': 'off',
'jsx-a11y/no-noninteractive-tabindex': 'off',
// common rules
'no-nested-ternary': 'warn',
'no-nested-ternary': 'error',
'no-constant-binary-expression': 'warn',
'no-unused-vars': [
'warn',

12
package-lock.json generated
View file

@ -61,7 +61,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.0.1",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.2.2",
"@librechat/agents": "^3.2.31",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -11706,9 +11706,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.2.tgz",
"integrity": "sha512-ibjAoUCWQ4zgIJfnhAr3O8JkUfRXbcvyH2/4NFNc9YgRHvxS5tPoJAWUnBmC6t0AOt89vaJAQQwTMWo2YEB9qw==",
"version": "3.2.31",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.31.tgz",
"integrity": "sha512-UofUny0p6DTFra5SaRZqwSZd/q8V0ekbckBWnkyZ68Aauf1ncu1cWorh2TkSc239JFYMtIxQMhQlNzrR6RRjCQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.92.0",
@ -11746,7 +11746,7 @@
"uuid": "^11.1.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=24.0.0"
},
"peerDependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.49"
@ -43970,7 +43970,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.0.1",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.2.2",
"@librechat/agents": "^3.2.31",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -77,6 +77,8 @@
"e2e:report": "npx playwright show-report e2e/playwright-report",
"lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",
"lint": "eslint \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",
"sort-imports": "node scripts/sort-imports.mts",
"sort-imports:check": "node scripts/sort-imports.mts --check",
"format": "npx prettier --write \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",
"b:api": "NODE_ENV=production bun run api/server/index.js",
"b:api-inspect": "NODE_ENV=production bun --inspect run api/server/index.js",

View file

@ -104,7 +104,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.0.1",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.2.2",
"@librechat/agents": "^3.2.31",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -421,11 +421,12 @@ describe('extractCodeArtifactText', () => {
['deck', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
])('routes extensionless office files by MIME alone (%s, %s)', async (name, mime) => {
mockOfficeHtml.mockResolvedValueOnce('<!DOCTYPE html><body>x</body></html>');
const category = mime.includes('presentation')
? 'pptx'
: mime.startsWith('text/')
? 'utf8-text'
: 'document';
let category: 'pptx' | 'utf8-text' | 'document' = 'document';
if (mime.includes('presentation')) {
category = 'pptx';
} else if (mime.startsWith('text/')) {
category = 'utf8-text';
}
const text = await extractCodeArtifactText(Buffer.from('PK'), name, mime, category);
expect(mockOfficeHtml).toHaveBeenCalledWith(expect.any(Buffer), name, mime);
expect(text).toContain('<body>');

View file

@ -987,12 +987,12 @@ function renderPptxSlidesBody(slides: PptxSlide[]): string {
const titleHtml = slide.title
? `<h2 class="lc-pptx-slide-title">${escapeHtml(slide.title)}</h2>`
: '';
const bodyHtml =
slide.body.length > 0
? `<ul class="lc-pptx-slide-body">${slide.body.map((line) => `<li>${escapeHtml(line)}</li>`).join('')}</ul>`
: !slide.title
? '<p class="lc-pptx-slide-empty">(empty slide)</p>'
: '';
let bodyHtml = '';
if (slide.body.length > 0) {
bodyHtml = `<ul class="lc-pptx-slide-body">${slide.body.map((line) => `<li>${escapeHtml(line)}</li>`).join('')}</ul>`;
} else if (!slide.title) {
bodyHtml = '<p class="lc-pptx-slide-empty">(empty slide)</p>';
}
return `<li class="lc-pptx-slide">
<span class="lc-pptx-slide-number">Slide ${slide.number}</span>
${titleHtml}

View file

@ -140,6 +140,10 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
}
});
return newVisibility;
/* isSmallScreen is intentionally a dependency: it forces a fresh result
reference when the viewport crosses the mobile breakpoint so the effect
below re-applies column visibility, even though the body doesn't read it. */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSmallScreen, columns]);
useEffect(() => {
@ -510,15 +514,24 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
?.width;
const widthStyle = isSelectHeader
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
: metaWidth && metaWidth >= 1 && metaWidth <= 100
? {
width: `${metaWidth}%`,
maxWidth: `${metaWidth}%`,
minWidth: `${metaWidth}%`,
}
: {};
let widthStyle: React.CSSProperties = {};
if (isSelectHeader) {
widthStyle = { width: '32px', maxWidth: '32px', minWidth: '32px' };
} else if (metaWidth != null && metaWidth >= 1 && metaWidth <= 100) {
widthStyle = {
width: `${metaWidth}%`,
maxWidth: `${metaWidth}%`,
minWidth: `${metaWidth}%`,
};
}
const sortDirection = header.column.getIsSorted();
let ariaSort: 'ascending' | 'descending' | undefined;
if (sortDirection === 'asc') {
ariaSort = 'ascending';
} else if (sortDirection === 'desc') {
ariaSort = 'descending';
}
return (
<TableHead
key={header.id}
@ -537,13 +550,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
aria-label={sortAriaLabel}
aria-sort={
header.column.getIsSorted() === 'asc'
? 'ascending'
: header.column.getIsSorted() === 'desc'
? 'descending'
: undefined
}
aria-sort={ariaSort}
>
{isSelectHeader ? (
flexRender(header.column.columnDef.header, header.getContext())

View file

@ -1,7 +1,7 @@
import React, { memo, forwardRef } from 'react';
import { flexRender } from '@tanstack/react-table';
import type { TableColumn } from './DataTable.types';
import type { Row } from '@tanstack/react-table';
import type { TableColumn } from './DataTable.types';
import { TableCell, TableRow, TableRowHeader } from '../Table';
import { Checkbox } from '../Checkbox';
import { Skeleton } from '../Skeleton';
@ -70,16 +70,16 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
const isDesktopOnly = meta?.desktopOnly;
const isRowHeader = meta?.isRowHeader;
const percent = meta?.width;
const widthStyle =
cell.column.id === 'select'
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
: percent
? {
width: `${percent}%`,
maxWidth: `${percent}%`,
minWidth: `${percent}%`, // Don't shrink on mobile
}
: undefined;
let widthStyle: React.CSSProperties | undefined;
if (cell.column.id === 'select') {
widthStyle = { width: '32px', maxWidth: '32px', minWidth: '32px' };
} else if (percent) {
widthStyle = {
width: `${percent}%`,
maxWidth: `${percent}%`,
minWidth: `${percent}%`, // Don't shrink on mobile
};
}
const CellComponent = isRowHeader ? TableRowHeader : TableCell;

257
scripts/sort-imports.mts Normal file
View file

@ -0,0 +1,257 @@
#!/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.`);
}