From bfb6b224d227f56672c7b58fe04e48b863f0e24a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 6 Jun 2026 12:31:55 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20chore:=20Update=20ESLint=20confi?= =?UTF-8?q?g,=20Import=20Sorting=20script,=20Test=20Sharding,=20Bump=20`@l?= =?UTF-8?q?ibrechat/agents`=20(#13552)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿ”ง 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. --- .github/workflows/backend-review.yml | 24 +- .github/workflows/eslint-ci.yml | 28 ++ .github/workflows/frontend-review.yml | 10 +- .husky/lint-staged.config.js | 7 +- api/package.json | 2 +- .../components/Chat/Input/CircleRender.tsx | 36 --- .../Nav/SettingsTabs/Balance/Balance.tsx | 44 +-- eslint.config.mjs | 2 +- package-lock.json | 12 +- package.json | 2 + packages/api/package.json | 2 +- packages/api/src/files/code/extract.spec.ts | 11 +- packages/api/src/files/documents/html.ts | 12 +- .../src/components/DataTable/DataTable.tsx | 39 +-- .../DataTable/DataTableComponents.tsx | 22 +- scripts/sort-imports.mts | 257 ++++++++++++++++++ 16 files changed, 396 insertions(+), 114 deletions(-) delete mode 100644 client/src/components/Chat/Input/CircleRender.tsx create mode 100644 scripts/sort-imports.mts diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index e25e884fee..87a22b4017 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -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 diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml index 3710f8a02e..4c5b52a53f 100644 --- a/.github/workflows/eslint-ci.yml +++ b/.github/workflows/eslint-ci.yml @@ -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 diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index b84d145bee..62eef692c8 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -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: diff --git a/.husky/lint-staged.config.js b/.husky/lint-staged.config.js index 482e1f050e..8aee5fba81 100644 --- a/.husky/lint-staged.config.js +++ b/.husky/lint-staged.config.js @@ -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'], }; diff --git a/api/package.json b/api/package.json index 91c0b8e160..a2caa8d0a5 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/client/src/components/Chat/Input/CircleRender.tsx b/client/src/components/Chat/Input/CircleRender.tsx deleted file mode 100644 index 5b46f65744..0000000000 --- a/client/src/components/Chat/Input/CircleRender.tsx +++ /dev/null @@ -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 ; - default: - return ( -
- -
- ); - } - }; - - 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; diff --git a/client/src/components/Nav/SettingsTabs/Balance/Balance.tsx b/client/src/components/Nav/SettingsTabs/Balance/Balance.tsx index 18b4368e0b..28a2efccdc 100644 --- a/client/src/components/Nav/SettingsTabs/Balance/Balance.tsx +++ b/client/src/components/Nav/SettingsTabs/Balance/Balance.tsx @@ -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 ( +
+ {localize('com_nav_balance_auto_refill_disabled')} +
+ ); + } + if (!hasValidRefillSettings) { + return ( +
{localize('com_nav_balance_auto_refill_error')}
+ ); + } + return ( + + ); + }; + return (
{/* Token credits display */} {/* Auto-refill display */} - {autoRefillEnabled ? ( - hasValidRefillSettings ? ( - - ) : ( -
- {localize('com_nav_balance_auto_refill_error')} -
- ) - ) : ( -
- {localize('com_nav_balance_auto_refill_disabled')} -
- )} + {renderAutoRefill()}
); } diff --git a/eslint.config.mjs b/eslint.config.mjs index adf52b44a6..636e62a6f5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/package-lock.json b/package-lock.json index 7931480bc6..d47785449f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4b54f1ff7f..e3578c4e63 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/api/package.json b/packages/api/package.json index d1cc530352..53724924b7 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/files/code/extract.spec.ts b/packages/api/src/files/code/extract.spec.ts index 1baa2490f1..1c8975a37b 100644 --- a/packages/api/src/files/code/extract.spec.ts +++ b/packages/api/src/files/code/extract.spec.ts @@ -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('x'); - 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(''); diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index 7bac37804c..7307685bbc 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -987,12 +987,12 @@ function renderPptxSlidesBody(slides: PptxSlide[]): string { const titleHtml = slide.title ? `

${escapeHtml(slide.title)}

` : ''; - const bodyHtml = - slide.body.length > 0 - ? `
    ${slide.body.map((line) => `
  • ${escapeHtml(line)}
  • `).join('')}
` - : !slide.title - ? '

(empty slide)

' - : ''; + let bodyHtml = ''; + if (slide.body.length > 0) { + bodyHtml = `
    ${slide.body.map((line) => `
  • ${escapeHtml(line)}
  • `).join('')}
`; + } else if (!slide.title) { + bodyHtml = '

(empty slide)

'; + } return `
  • Slide ${slide.number} ${titleHtml} diff --git a/packages/client/src/components/DataTable/DataTable.tsx b/packages/client/src/components/DataTable/DataTable.tsx index 4320b86db3..e7244e68d2 100644 --- a/packages/client/src/components/DataTable/DataTable.tsx +++ b/packages/client/src/components/DataTable/DataTable.tsx @@ -140,6 +140,10 @@ function DataTable, 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, 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 ( , 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()) diff --git a/packages/client/src/components/DataTable/DataTableComponents.tsx b/packages/client/src/components/DataTable/DataTableComponents.tsx index d058ad409b..d291f763c0 100644 --- a/packages/client/src/components/DataTable/DataTableComponents.tsx +++ b/packages/client/src/components/DataTable/DataTableComponents.tsx @@ -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 = >( 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; diff --git a/scripts/sort-imports.mts b/scripts/sort-imports.mts new file mode 100644 index 0000000000..2d89608f76 --- /dev/null +++ b/scripts/sort-imports.mts @@ -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 { + 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 { + 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.`); +}