From 8d1404bf79d7134ed03ecf3e42943cc956d57a07 Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 31 Jul 2026 13:02:54 +0800 Subject: [PATCH 1/2] feat(fmt): add file path discovery --- packages/rstack/THIRD_PARTY_NOTICES.md | 57 ++++ packages/rstack/package.json | 2 + packages/rstack/src/fmt/discoverPaths.ts | 249 ++++++++++++++++++ .../rstack/tests/fmt/discoverPaths.test.ts | 107 ++++++++ pnpm-lock.yaml | 45 ++++ pnpm-workspace.yaml | 2 + 6 files changed, 462 insertions(+) create mode 100644 packages/rstack/src/fmt/discoverPaths.ts create mode 100644 packages/rstack/tests/fmt/discoverPaths.test.ts diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index 67033d5..4897ebd 100644 --- a/packages/rstack/THIRD_PARTY_NOTICES.md +++ b/packages/rstack/THIRD_PARTY_NOTICES.md @@ -58,6 +58,35 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## is-binary-path + +This package includes bundled code from [is-binary-path](https://github.com/sindresorhus/is-binary-path). + +License: MIT + +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) +Copyright (c) Paul Miller (https://paulmillr.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## micromatch This package includes bundled code from [micromatch](https://github.com/micromatch/micromatch). @@ -197,3 +226,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## tiny-readdir + +This package includes bundled code from [tiny-readdir](https://github.com/fabiospampinato/tiny-readdir). + +License: MIT + +The MIT License (MIT) + +Copyright (c) 2020-present Fabio Spampinato + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/rstack/package.json b/packages/rstack/package.json index 3ec3e36..a33ec46 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -68,9 +68,11 @@ "@types/micromatch": "catalog:", "@types/node": "catalog:", "fast-ignore": "catalog:", + "is-binary-path": "catalog:", "lint-staged": "catalog:", "micromatch": "catalog:", "rslog": "catalog:", + "tiny-readdir": "catalog:", "typescript": "catalog:" }, "peerDependencies": { diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts new file mode 100644 index 0000000..205e3b4 --- /dev/null +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -0,0 +1,249 @@ +import { lstat } from 'node:fs/promises'; +import path from 'node:path'; +import isBinaryPath from 'is-binary-path'; +import micromatch from 'micromatch'; +import readdir, { type Dirent } from 'tiny-readdir'; + +const alwaysIgnoredNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); + +interface DiscoverFmtPathsOptions { + /** Absolute directory used to resolve input paths. */ + cwd: string; + patterns?: string[]; +} + +const isErrnoException = (error: unknown): error is NodeJS.ErrnoException => + error instanceof Error && 'code' in error; + +const lstatSafe = async (filePath: string) => { + try { + return await lstat(filePath); + } catch (error) { + if (!isErrnoException(error) || error.code !== 'ENOENT') { + throw error; + } + } +}; + +const isRelativePathInside = (relativePath: string): boolean => + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath); + +const isPathInside = (rootPath: string, filePath: string): boolean => + isRelativePathInside(path.relative(rootPath, filePath)); + +const toPosixPath = (filePath: string): string => + path.sep === '\\' ? filePath.replaceAll('\\', '/') : filePath; + +/** Supports both the legacy tiny-readdir type and Node.js 24 Dirent. */ +const getDirentParentPath = (dirent: Dirent): string => + (dirent as Dirent & { parentPath?: string }).parentPath ?? dirent.path; + +/** Mirrors the path passed to tiny-readdir's ignore callback. */ +const getDirentPath = (dirent: Dirent, parentPath: string): string => + `${parentPath}${parentPath === path.sep ? '' : path.sep}${dirent.name}`; + +const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean => + path + .relative(cwd, filePath) + .split(path.sep) + .some((segment) => alwaysIgnoredNames.has(segment)); + +const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => { + // tiny-readdir passes only a path to `ignore`, so retain the dirent type briefly. + const directories = new Set(); + + return { + followSymlinks: false, + ignore: (targetPath: string) => { + const isDirectory = directories.delete(targetPath); + if (alwaysIgnoredNames.has(path.basename(targetPath))) { + return true; + } + + return ( + !isDirectory && + (isBinaryPath(targetPath) || (isIncluded !== undefined && !isIncluded(targetPath))) + ); + }, + onDirents: (dirents: Dirent[]) => { + const parentPath = getDirentParentPath(dirents[0]); + + for (const dirent of dirents) { + if (dirent.isDirectory()) { + directories.add(getDirentPath(dirent, parentPath)); + } + } + + return undefined; + }, + }; +}; + +const normalizeGlob = (cwd: string, pattern: string): string => { + const relativePattern = path.isAbsolute(pattern) ? path.relative(cwd, pattern) : pattern; + return toPosixPath(relativePattern); +}; + +type PatternEntry = { + kind: 'file' | 'directory' | 'glob' | 'negative-glob'; + value: string; +}; + +type ClassifiedPatterns = { + files: string[]; + directories: string[]; + globs: string[]; + negativeGlobs: string[]; +}; + +const classifyPatterns = async (cwd: string, patterns: string[]): Promise => { + const entries = await Promise.all( + patterns.map(async (pattern): Promise => { + if (pattern.startsWith('!')) { + return { kind: 'negative-glob', value: normalizeGlob(cwd, pattern.slice(1)) }; + } + + const filePath = path.resolve(cwd, pattern); + if (hasAlwaysIgnoredSegment(cwd, filePath)) { + return; + } + + const stats = await lstatSafe(filePath); + if (stats?.isFile()) { + return { kind: 'file', value: filePath }; + } + if (stats?.isDirectory()) { + return { kind: 'directory', value: filePath }; + } + if (stats) { + return; + } + + return { kind: 'glob', value: normalizeGlob(cwd, pattern) }; + }), + ); + + const result: ClassifiedPatterns = { + files: [], + directories: [], + globs: [], + negativeGlobs: [], + }; + + for (const entry of entries) { + if (!entry) { + continue; + } + + switch (entry.kind) { + case 'file': + result.files.push(entry.value); + break; + case 'directory': + result.directories.push(entry.value); + break; + case 'glob': + result.globs.push(entry.value); + break; + case 'negative-glob': + result.negativeGlobs.push(entry.value); + break; + } + } + + return result; +}; + +const getOutermostPaths = (paths: string[]): string[] => { + const sortedPaths = [...new Set(paths)].sort((left, right) => left.length - right.length); + const outermostPaths: string[] = []; + + for (const filePath of sortedPaths) { + if (!outermostPaths.some((parentPath) => isPathInside(parentPath, filePath))) { + outermostPaths.push(filePath); + } + } + + return outermostPaths; +}; + +/** Merges overlapping roots; micromatch remains responsible for glob syntax. */ +const getTraversalRoots = (cwd: string, directories: string[], globs: string[]): string[] => { + const globRoots = globs.map((pattern) => path.resolve(cwd, micromatch.scan(pattern).base || '.')); + + return getOutermostPaths([...directories, ...globRoots]); +}; + +const discoverFmtPaths = async ({ + cwd, + patterns: inputPatterns, +}: DiscoverFmtPathsOptions): Promise => { + const patterns = inputPatterns?.length ? inputPatterns : ['.']; + const { + files: explicitFiles, + directories, + globs, + negativeGlobs, + } = await classifyPatterns(cwd, patterns); + const directoryRoots = getOutermostPaths(directories); + const globMatchers = globs.map((pattern) => micromatch.matcher(pattern, { dot: true })); + const candidates = new Set(explicitFiles); + const traversalRoots = getTraversalRoots(cwd, directoryRoots, globs); + + const results = await Promise.all( + traversalRoots.map(async (rootPath) => { + const stats = await lstatSafe(rootPath); + if (!stats?.isDirectory()) { + return []; + } + + const includesAll = directoryRoots.some((directoryPath) => + isPathInside(directoryPath, rootPath), + ); + const isIncluded = includesAll + ? undefined + : (filePath: string): boolean => { + if (directoryRoots.some((directoryPath) => isPathInside(directoryPath, filePath))) { + return true; + } + + const relativePath = toPosixPath(path.relative(cwd, filePath)); + return globMatchers.some((matches) => matches(relativePath)); + }; + + return (await readdir(rootPath, createTraversalOptions(isIncluded))).files; + }), + ); + + for (const files of results) { + for (const filePath of files) { + candidates.add(filePath); + } + } + + const negativeGlobMatchers = negativeGlobs.map((pattern) => + micromatch.matcher(pattern, { dot: true }), + ); + const filePaths: string[] = []; + + for (const filePath of candidates) { + if (isBinaryPath(filePath)) { + continue; + } + + if (negativeGlobMatchers.length) { + const relativePath = toPosixPath(path.relative(cwd, filePath)); + if (negativeGlobMatchers.some((matches) => matches(relativePath))) { + continue; + } + } + + filePaths.push(filePath); + } + + return filePaths.sort(); +}; + +export { discoverFmtPaths }; diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts new file mode 100644 index 0000000..16a699a --- /dev/null +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -0,0 +1,107 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { discoverFmtPaths } from '../../src/fmt/discoverPaths.ts'; + +const withProject = async (callback: (rootPath: string) => Promise): Promise => { + const rootPath = mkdtempSync(path.join(tmpdir(), 'rstack fmt ')); + + try { + await callback(rootPath); + } finally { + rmSync(rootPath, { force: true, recursive: true }); + } +}; + +const writeProjectFile = (rootPath: string, filePath: string, content = ''): string => { + const absolutePath = path.join(rootPath, filePath); + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content); + return absolutePath; +}; + +const relativePaths = (rootPath: string, files: string[]): string[] => + files.map((filePath) => path.relative(rootPath, filePath)); + +test('discovers non-binary files in stable order and skips hard-ignored paths', async () => { + await withProject(async (rootPath) => { + writeProjectFile(rootPath, 'b.ts'); + writeProjectFile(rootPath, 'a.js'); + writeProjectFile(rootPath, 'folder with spaces/c.ts'); + writeProjectFile(rootPath, 'unknown.extension'); + writeProjectFile(rootPath, 'image.png'); + writeProjectFile(rootPath, 'node_modules/package/index.js'); + writeProjectFile(rootPath, '.git/internal.js'); + writeProjectFile(rootPath, '.jj/internal.js'); + + const files = await discoverFmtPaths({ cwd: rootPath }); + + expect(relativePaths(rootPath, files)).toEqual([ + 'a.js', + 'b.ts', + path.join('folder with spaces', 'c.ts'), + 'unknown.extension', + ]); + await expect( + discoverFmtPaths({ cwd: rootPath, patterns: ['node_modules/package/index.js'] }), + ).resolves.toEqual([]); + }); +}); + +test('combines files, directories, and globs without duplicates', async () => { + await withProject(async (rootPath) => { + const firstFilePath = writeProjectFile(rootPath, 'src/a.ts'); + writeProjectFile(rootPath, 'src/b.js'); + writeProjectFile(rootPath, 'test/c.ts'); + writeProjectFile(rootPath, 'dot/.hidden.ts'); + + const files = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['src', 'src/**/*.ts', firstFilePath, '!**/b.js'], + }); + const extglobFiles = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['src/@(a.ts|b.js)'], + }); + const braceFiles = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['{src,test}/**/*.ts'], + }); + const dotFiles = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['dot/**/*.ts'], + }); + + expect(relativePaths(rootPath, files)).toEqual([path.join('src', 'a.ts')]); + expect(relativePaths(rootPath, extglobFiles)).toEqual([ + path.join('src', 'a.ts'), + path.join('src', 'b.js'), + ]); + expect(relativePaths(rootPath, braceFiles)).toEqual([ + path.join('src', 'a.ts'), + path.join('test', 'c.ts'), + ]); + expect(relativePaths(rootPath, dotFiles)).toEqual([path.join('dot', '.hidden.ts')]); + await expect( + discoverFmtPaths({ cwd: rootPath, patterns: ['missing/**/*.ts'] }), + ).resolves.toEqual([]); + }); +}); + +test.runIf(process.platform !== 'win32')('does not follow file or directory symlinks', async () => { + await withProject(async (rootPath) => { + const targetPath = writeProjectFile(rootPath, 'target/index.ts'); + symlinkSync(path.join(rootPath, 'target'), path.join(rootPath, 'linked-directory')); + symlinkSync(targetPath, path.join(rootPath, 'linked-file.ts')); + + const discoveredFiles = await discoverFmtPaths({ cwd: rootPath }); + const explicitFiles = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['linked-directory', 'linked-file.ts'], + }); + + expect(relativePaths(rootPath, discoveredFiles)).toEqual([path.join('target', 'index.ts')]); + expect(explicitFiles).toEqual([]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6476582..d433f69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ catalogs: heading-case: specifier: ^1.1.4 version: 1.1.4 + is-binary-path: + specifier: 3.0.0 + version: 3.0.0 lint-staged: specifier: ^17.2.0 version: 17.2.0 @@ -109,6 +112,9 @@ catalogs: rspress-plugin-font-open-sans: specifier: ^1.0.4 version: 1.0.4 + tiny-readdir: + specifier: 2.7.4 + version: 2.7.4 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -349,6 +355,9 @@ importers: fast-ignore: specifier: 'catalog:' version: 2.0.0 + is-binary-path: + specifier: 'catalog:' + version: 3.0.0 lint-staged: specifier: 'catalog:' version: 17.2.0 @@ -358,6 +367,9 @@ importers: rslog: specifier: 'catalog:' version: 2.3.0 + tiny-readdir: + specifier: 'catalog:' + version: 2.7.4 typescript: specifier: 'catalog:' version: 7.0.2 @@ -1160,6 +1172,10 @@ packages: big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + binary-extensions@3.1.0: + resolution: {integrity: sha512-Jvvd9hy1w+xUad8+ckQsWA/V1AoyubOvqn0aygjMOVM4BfIaRav1NFS3LsTSDaV4n4FtcCtQXvzep1E6MboqwQ==} + engines: {node: '>=18.20'} + body-scroll-lock@4.0.0-beta.0: resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==} @@ -1412,6 +1428,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-binary-path@3.0.0: + resolution: {integrity: sha512-eSkpSYbqKip82Uw4z0iBK/5KmVzL2pf36kNKRtu6+mKvrow9sqF4w5hocQ9yV5v+9+wzHt620x3B7Wws/8lsGg==} + engines: {node: '>=18.20'} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -1738,6 +1758,12 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + promise-make-counter@1.0.2: + resolution: {integrity: sha512-FJAxTBWQuQoAs4ZOYuKX1FHXxEgKLEzBxUvwr4RoOglkTpOjWuM+RXsK3M9q5lMa8kjqctUrhwYeZFT4ygsnag==} + + promise-make-naked@3.0.2: + resolution: {integrity: sha512-B+b+kQ1YrYS7zO7P7bQcoqqMUizP06BOyNSBEnB5VJKDSWo8fsVuDkfSmwdjF0JsRtaNh83so5MMFJ95soH5jg==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -2090,6 +2116,9 @@ packages: resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} engines: {node: '>=16.0.0'} + tiny-readdir@2.7.4: + resolution: {integrity: sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -2866,6 +2895,8 @@ snapshots: big.js@5.2.2: {} + binary-extensions@3.1.0: {} + body-scroll-lock@4.0.0-beta.0: {} braces@3.0.3: @@ -3176,6 +3207,10 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-binary-path@3.0.0: + dependencies: + binary-extensions: 3.1.0 + is-decimal@2.0.1: {} is-extglob@2.1.1: @@ -3767,6 +3802,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + promise-make-counter@1.0.2: + dependencies: + promise-make-naked: 3.0.2 + + promise-make-naked@3.0.2: {} + property-information@7.2.0: {} react-dom@19.2.8(react@19.2.8): @@ -4129,6 +4170,10 @@ snapshots: sync-message-port@1.2.0: {} + tiny-readdir@2.7.4: + dependencies: + promise-make-counter: 1.0.2 + tinyexec@1.2.4: {} tinyglobby@0.2.17: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 57c78d5..d28d3e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,6 +37,7 @@ catalog: 'fast-ignore': '2.0.0' 'happy-dom': '^20.11.1' 'heading-case': '^1.1.4' + is-binary-path: 3.0.0 'lint-staged': '^17.2.0' 'micromatch': '4.0.8' prettier: '3.9.6' @@ -46,6 +47,7 @@ catalog: 'rsbuild-plugin-open-graph': '^1.1.3' rslog: ^2.3.0 'rspress-plugin-font-open-sans': '^1.0.4' + tiny-readdir: 2.7.4 'typescript': '^7.0.2' dedupePeers: true From 44cc5b259e68e0bb5966e7ff0b51b5748243ab28 Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 31 Jul 2026 13:09:02 +0800 Subject: [PATCH 2/2] chore: update spelling dictionary --- scripts/dictionary.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 2e55352..f9dc75e 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -1,6 +1,8 @@ # Custom Dictionary Words applypatch +dirents errexit +extglob fnames huskyrc llms