From 0087ccc38f9c2c7a888d4053e516b2f03c69049a Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 13:59:42 +0800 Subject: [PATCH 01/22] chore: add rstack package metadata (#192) --- packages/rstack/package.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/rstack/package.json b/packages/rstack/package.json index ba0b3322..d11273c0 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -1,7 +1,16 @@ { "name": "rstack", "version": "0.3.3", - "repository": "https://github.com/rstackjs/rstack-cli", + "description": "One CLI for JavaScript development, powered by Rstack.", + "homepage": "https://rstack.rs", + "bugs": { + "url": "https://github.com/rstackjs/rstack-cli/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rstackjs/rstack-cli.git", + "directory": "packages/rstack" + }, "license": "MIT", "type": "module", "exports": { From 31c54dc20d4e23491abf746ff554af398254096f Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 15:04:03 +0800 Subject: [PATCH 02/22] docs: update credits (#193) --- README.md | 7 +++++-- scripts/dictionary.txt | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d91361f9..017d2e3c 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,13 @@ Rstack CLI is inspired by: - [Bun](https://github.com/oven-sh/bun) - [Cargo](https://github.com/rust-lang/cargo) - [Deno](https://github.com/denoland/deno) -- [Husky](https://github.com/typicode/husky) -- [Prettier](https://github.com/prettier/prettier) +- [Oxfmt](https://github.com/oxc-project/oxc) - [Vite Plus](https://github.com/voidzero-dev/vite-plus) +Parts of the Git hook implementation are derived from [Husky](https://github.com/typicode/husky), and parts of the formatter runtime are derived from [Prettier CLI](https://github.com/prettier/prettier-cli). + +See [Third-Party Notices](./packages/rstack/THIRD_PARTY_NOTICES.md) for complete attribution and license information. + ## License [MIT](./LICENSE). diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 942bbe64..af854b59 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -10,6 +10,7 @@ llms noformat noprettier nosystem +oxfmt quasis rsbuild rslib From 52dc7a4a7e3d8762438bc5f711b20274e77b54a8 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 16:13:56 +0800 Subject: [PATCH 03/22] chore(release): include Markdown docs in package (#194) --- .github/workflows/release.yml | 3 ++ package.json | 1 + scripts/prepare-release.js | 74 +++++++++++++++++++++++++++++++++++ website/rstack.config.ts | 4 ++ 4 files changed, 82 insertions(+) create mode 100644 scripts/prepare-release.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5abdb2a..0f039d57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,9 @@ jobs: - name: Build run: node --run build + - name: Prepare release + run: node --run release:prepare + - name: Publish to npm run: | pnpm --filter './packages/*' -r stage publish --tag ${{ github.event.inputs.npm_tag }} --no-git-checks diff --git a/package.json b/package.json index 93ea8261..4c40375e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format": "rs fmt && heading-case --write", "lint": "rs lint --type-check", "prepare": "node scripts/rs.js setup", + "release:prepare": "node scripts/prepare-release.js", "test": "pnpm --filter './packages/**' test" }, "devDependencies": { diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js new file mode 100644 index 00000000..3038f118 --- /dev/null +++ b/scripts/prepare-release.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { copyFile, mkdir, readdir, rm } from 'node:fs/promises'; +import path from 'node:path'; + +const rootDir = path.resolve(import.meta.dirname, '..'); +const websiteDir = path.join(rootDir, 'website'); +const websiteDistDir = path.join(websiteDir, 'doc_build'); +const packageDocsDir = path.join(rootDir, 'packages/rstack/dist/docs'); + +const run = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: rootDir, + env: { + ...process.env, + RSPRESS_INJECT_LLMS_HINT: 'false', + }, + stdio: 'inherit', + }); + + child.on('error', reject); + child.on('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + const reason = signal ? `signal ${signal}` : `exit code ${code}`; + reject(new Error(`${command} ${args.join(' ')} failed with ${reason}.`)); + }); + }); + +const collectMarkdownFiles = async (directory, relativeDir = '') => { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory() && entry.name === 'zh') { + continue; + } + + const relativePath = path.join(relativeDir, entry.name); + const absolutePath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...(await collectMarkdownFiles(absolutePath, relativePath))); + } else if (entry.isFile() && path.extname(entry.name) === '.md') { + files.push(relativePath); + } + } + + return files; +}; + +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +console.log('Building the website...'); +await run(pnpmCommand, ['--dir', websiteDir, 'build']); + +const markdownFiles = await collectMarkdownFiles(websiteDistDir); +if (markdownFiles.length === 0) { + throw new Error(`No English Markdown files found in ${websiteDistDir}.`); +} + +await rm(packageDocsDir, { recursive: true, force: true }); + +for (const relativePath of markdownFiles) { + const destination = path.join(packageDocsDir, relativePath); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(path.join(websiteDistDir, relativePath), destination); +} + +console.log(`Copied ${markdownFiles.length} English Markdown files to ${packageDocsDir}.`); diff --git a/website/rstack.config.ts b/website/rstack.config.ts index c78997c8..79821ca1 100644 --- a/website/rstack.config.ts +++ b/website/rstack.config.ts @@ -6,6 +6,7 @@ const title = 'Rstack CLI'; const description = 'Rstack CLI brings the Rstack toolchain together with one CLI, one configuration, and one consistent workflow.'; const descriptionZh = 'Rstack CLI 通过统一的命令行、配置和工作流整合 Rstack 工具链。'; +const injectLlmsHint = process.env.RSPRESS_INJECT_LLMS_HINT !== 'false'; define.doc(async () => { const { pluginSass } = await import('@rsbuild/plugin-sass'); @@ -57,6 +58,9 @@ define.doc(async () => { pluginSitemap({ siteUrl }), ], themeConfig: { + llmsUI: { + injectLlmsHint, + }, socialLinks: [ { icon: 'github', From 1832423c3334a2a4238f5ff1b2f81482a0b5ee57 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 16:33:20 +0800 Subject: [PATCH 04/22] feat(fmt): add -u alias for ignore unknown (#195) --- packages/rstack/src/fmt/cli.ts | 4 ++-- packages/rstack/tests/cli/fmt/index.test.ts | 12 +++++++++++- .../rstack/tests/fmt/__snapshots__/cli.test.ts.snap | 2 +- packages/rstack/tests/fmt/cli.test.ts | 2 +- website/docs/en/guide/cli/fmt.mdx | 8 +++++++- website/docs/zh/guide/cli/fmt.mdx | 8 +++++++- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index cff04bbc..0dc1347c 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -32,7 +32,7 @@ ${color.cyan('Options')}: --check Check whether files are formatted --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) - --ignore-unknown Ignore unknown files + -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match --parallel-workers Number of parallel workers --stdin-filepath Format stdin as if it were saved at @@ -59,7 +59,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { check: { type: 'boolean' }, 'list-different': { type: 'boolean' }, 'ignore-path': { type: 'string', multiple: true }, - 'ignore-unknown': { type: 'boolean' }, + 'ignore-unknown': { type: 'boolean', short: 'u' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, 'stdin-filepath': { type: 'string' }, diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 33ba3ca7..7f568af2 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -640,7 +640,7 @@ test('ignores unsupported files with --ignore-unknown', () => { writeProjectFile('notes.unknown', 'plain text'); for (const modeArgs of [[], ['--check'], ['--list-different']]) { - const result = runFmt([...modeArgs, '--ignoreUnknown', 'notes.unknown']); + const result = runFmt([...modeArgs, '--ignore-unknown', 'notes.unknown']); expect(result.status).toBe(0); expect(result.stdout).toBe( @@ -652,6 +652,16 @@ test('ignores unsupported files with --ignore-unknown', () => { } }); +test('supports -u as an alias for --ignore-unknown', () => { + writeProjectFile('notes.unknown', 'plain text'); + + const result = runFmt(['-u', 'notes.unknown']); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); +}); + test('does not treat unmatched patterns as unknown files', () => { const result = runFmt(['--ignore-unknown', 'missing/**/*.unknown']); diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index 62ae1697..2df10bdd 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -11,7 +11,7 @@ Options: --check Check whether files are formatted --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) - --ignore-unknown Ignore unknown files + -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match --parallel-workers Number of parallel workers --stdin-filepath Format stdin as if it were saved at diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index cf5e1908..9521ccb8 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -107,7 +107,7 @@ test('parses --no-error-on-unmatched-pattern', () => { expect(parseFmtCLIArgs(['--no-error-on-unmatched-pattern']).noErrorOnUnmatchedPattern).toBe(true); }); -test.each(['--ignore-unknown', '--ignoreUnknown'])('parses %s', (option) => { +test.each(['-u', '--ignore-unknown', '--ignoreUnknown'])('parses %s', (option) => { expect(parseFmtCLIArgs([option]).ignoreUnknown).toBe(true); }); diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index 818140e1..b92ac261 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -92,7 +92,13 @@ Each file acts as a separate ignore source. See [Ignore order](../formatting#ign Ignore matched files when no parser can be inferred. This allows the command to exit successfully even when every matched file has an unknown type: ```bash -rs fmt --ignore-unknown '**/*' +rs fmt --ignore-unknown +``` + +The short option `-u` is an alias for `--ignore-unknown`. + +```bash +rs fmt -u ``` This option does not suppress errors for unmatched paths or globs. Combine it with [`--no-error-on-unmatched-pattern`](#--no-error-on-unmatched-pattern) when an integration needs to tolerate both cases. diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index 3c810f3e..78fd64f2 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -92,7 +92,13 @@ rs fmt --ignore-path .prettierignore --ignore-path config/format.ignore 忽略无法推断 parser 的匹配文件。即使所有匹配文件的类型均未知,该选项也可以让命令成功退出: ```bash -rs fmt --ignore-unknown '**/*' +rs fmt --ignore-unknown +``` + +短选项 `-u` 是 `--ignore-unknown` 的别名。 + +```bash +rs fmt -u ``` 此选项不会忽略未匹配路径或 glob 的错误。如果集成需要同时容忍这两种情况,可以将它与 [`--no-error-on-unmatched-pattern`](#--no-error-on-unmatched-pattern) 一起使用。 From 9b9c1b3c2e2edf6e99a5ed7569276979bd7091bc Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 17:13:10 +0800 Subject: [PATCH 05/22] docs(skills): use bundled Rstack docs index (#196) --- .../skills/rstack-cli-best-practices/SKILL.md | 87 +++---------------- scripts/prepare-release.js | 10 ++- 2 files changed, 18 insertions(+), 79 deletions(-) diff --git a/.agents/skills/rstack-cli-best-practices/SKILL.md b/.agents/skills/rstack-cli-best-practices/SKILL.md index 11b6ac4a..21940ca7 100644 --- a/.agents/skills/rstack-cli-best-practices/SKILL.md +++ b/.agents/skills/rstack-cli-best-practices/SKILL.md @@ -1,92 +1,25 @@ --- name: rstack-cli-best-practices -description: Guidance on using Rstack CLI, including `rs` commands, the `rstack.config.ts` file, and import paths from the `rstack` package. Use for Rstack CLI-related tasks. +description: Guidance for Rstack CLI work involving `rs` commands, `rstack.config.*`, package APIs, or Rstack-based projects and tooling. --- # Rstack CLI Best Practices -Rstack CLI is the `rstack` package, exposed through the `rs` binaries. It provides one CLI, one config file, and a consistent workflow for the Rstack JavaScript toolchain. +Rstack CLI is the `rstack` package, exposed through the `rs` binaries. It provides one CLI, one +config file, and a consistent workflow for the Rstack JavaScript toolchain. It covers web app, library, docs, test, lint, formatting, Git hook, and staged-file workflows. -## Commands +## ALWAYS read installed docs before working -Use `rs -h` for top-level help, and `rs -h` for command help where supported. +Before any Rstack work, find and read the relevant Markdown documentation shipped with the installed `rstack` package. -| Command | Purpose | Underlying tool | Config | -| ------------ | -------------------------------- | --------------- | --------------- | -| `rs dev` | Run the app dev server | Rsbuild | `define.app` | -| `rs build` | Build the app for production | Rsbuild | `define.app` | -| `rs preview` | Preview the app production build | Rsbuild | `define.app` | -| `rs lib` | Build a library | Rslib | `define.lib` | -| `rs doc` | Serve or build docs | Rspress | `define.doc` | -| `rs test` | Run tests | Rstest | `define.test` | -| `rs lint` | Lint code | Rslint | `define.lint` | -| `rs fmt` | Format code | Prettier | `define.fmt` | -| `rs setup` | Install project-local Git hooks | None | None | -| `rs staged` | Run tasks on staged Git files | lint-staged | `define.staged` | +Model knowledge can be outdated; the installed documentation is the source of truth for the project's Rstack version. -Key behavior: +1. Start with `node_modules/rstack/dist/docs/llms.txt`, then read only the linked pages relevant to the task before proposing or making changes. -- Unless `define.test` already sets `extends`, `rs test` extends `define.app` through `@rstest/adapter-rsbuild` or falls back to `define.lib` through `@rstest/adapter-rslib`. The app config takes precedence when both are defined. -- `rs doc` requires the optional `@rspress/core` dependency. +2. For exact CLI flags and behavior, also run `rs -h` or `rs -h` when supported. -## rstack.config.ts +If the bundled docs are not available at that path, locate the installed `rstack` package. -Rstack CLI loads `rstack.config.{ts,js,mts,mjs}` by default. - -Register config with `define.*`: - -```ts -import { define } from 'rstack'; - -define.app({ - // Rsbuild config for `rs dev`, `rs build`, and `rs preview` -}); - -define.test({ - // Rstest config for `rs test` -}); -``` - -- `define.app(config)`: Rsbuild config for `rs dev`, `rs build`, and `rs preview`. Docs: https://rsbuild.rs/config/ -- `define.lib(config)`: Rslib config for `rs lib`; Docs: https://rslib.rs/config/ -- `define.doc(config)`: Rspress config for `rs doc`; Docs: https://rspress.rs/api/config/config-basic -- `define.test(config)`: Rstest config for `rs test`; Docs: https://rstest.rs/config/ -- `define.lint(config)`: Rslint config for `rs lint`; Docs: https://rslint.rs/config/ -- `define.fmt(config)`: Formatting options for `rs fmt`. -- `define.staged(config)`: lint-staged config for `rs staged`; accepts `Record`. - -### Lazy Configuration - -Prefer async functions with dynamic imports for dependencies. Avoid top-level sync imports of heavy dependencies in `rstack.config.ts`. - -```ts -import { define } from 'rstack'; - -define.app(async () => { - const { pluginReact } = await import('@rsbuild/plugin-react'); - return { - plugins: [pluginReact()], - }; -}); -``` - -## Import Paths - -Prefer Rstack-exported paths: - -| Instead of | Prefer | -| ------------------------- | ------------------------ | -| `@rsbuild/core` | `rstack/app` | -| `@rslib/core` | `rstack/lib` | -| `@rstest/core` | `rstack/test` | -| `@rslint/core` | `rstack/lint` | -| `@rsbuild/core/types` | `rstack/types` | -| `@rslib/core/types` | `rstack/types` | -| `@rstest/core/globals` | `rstack/test/globals` | -| `@rstest/core/importMeta` | `rstack/test/importMeta` | - -## Git Hooks - -Use [`rs setup`](https://rstack.rs/guide/cli/setup) for project-local Git hooks, commonly with `rs staged` in a `pre-commit` hook. +If they are still unavailable, verify that `rstack` is installed, and use CLI help plus the online [Rstack documentation](https://rstack.rs/) as a fallback. diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js index 3038f118..0cdec3b7 100644 --- a/scripts/prepare-release.js +++ b/scripts/prepare-release.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; -import { copyFile, mkdir, readdir, rm } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; const rootDir = path.resolve(import.meta.dirname, '..'); @@ -71,4 +71,10 @@ for (const relativePath of markdownFiles) { await copyFile(path.join(websiteDistDir, relativePath), destination); } -console.log(`Copied ${markdownFiles.length} English Markdown files to ${packageDocsDir}.`); +const llmsTxt = await readFile(path.join(websiteDistDir, 'llms.txt'), 'utf8'); +const packageLlmsTxt = llmsTxt.replace(/\]\(\/(?!\/)/g, '](./'); +await writeFile(path.join(packageDocsDir, 'llms.txt'), packageLlmsTxt); + +console.log( + `Copied ${markdownFiles.length} English Markdown files and llms.txt to ${packageDocsDir}.`, +); From 9e5774422ffd3818fdc9703abff13d10e0554c39 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 17:34:46 +0800 Subject: [PATCH 06/22] feat(fmt): add --with-node-modules (#197) --- packages/rstack/src/fmt/cli.ts | 7 ++++ packages/rstack/src/fmt/discoverPaths.ts | 39 +++++++++++++++---- packages/rstack/src/fmt/discovery.ts | 2 + packages/rstack/src/fmt/types.ts | 2 + packages/rstack/tests/cli/fmt/index.test.ts | 15 +++++++ .../tests/fmt/__snapshots__/cli.test.ts.snap | 1 + packages/rstack/tests/fmt/cli.test.ts | 11 ++++++ .../rstack/tests/fmt/discoverPaths.test.ts | 27 +++++++++++++ website/docs/en/guide/cli/fmt.mdx | 10 +++++ website/docs/zh/guide/cli/fmt.mdx | 10 +++++ 10 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 0dc1347c..7cada943 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -14,6 +14,7 @@ interface ParsedFmtCLIArgs { ignorePaths: string[]; ignoreUnknown: boolean; noErrorOnUnmatchedPattern: boolean; + withNodeModules: boolean; maxWorkers?: number; help: boolean; /** Path the stdin content is formatted as; it need not exist on disk. */ @@ -34,6 +35,7 @@ ${color.cyan('Options')}: --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match + --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers --stdin-filepath Format stdin as if it were saved at -h, --help Display this help message`; @@ -61,6 +63,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { 'ignore-path': { type: 'string', multiple: true }, 'ignore-unknown': { type: 'boolean', short: 'u' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, + 'with-node-modules': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, 'stdin-filepath': { type: 'string' }, help: { type: 'boolean', short: 'h' }, @@ -81,6 +84,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const ignorePaths = values.ignorePath ?? []; const ignoreUnknown = values.ignoreUnknown ?? false; const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false; + const withNodeModules = values.withNodeModules ?? false; const parallelWorkers = values.parallelWorkers; const maxWorkers = parseMaxWorkers(parallelWorkers); const help = values.help ?? false; @@ -104,6 +108,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { ignorePaths, ignoreUnknown, noErrorOnUnmatchedPattern, + withNodeModules, maxWorkers, help, stdinFilepath, @@ -239,6 +244,7 @@ const runFmtCLI = async (args: string[]): Promise => { noErrorOnUnmatchedPattern, patterns, stdinFilepath, + withNodeModules, } = parseFmtCLIArgs(args); if (help) { logger.log(fmtHelpMessage); @@ -266,6 +272,7 @@ const runFmtCLI = async (args: string[]): Promise => { patterns, config, ignorePaths, + withNodeModules, }); if (files.length === 0) { diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index f0e16f45..acc013d7 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -5,12 +5,14 @@ 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']); +const defaultIgnoredDirNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); interface DiscoverFmtPathsOptions { /** Absolute directory used to resolve input paths. */ cwd: string; patterns?: string[]; + /** Whether files inside node_modules may be discovered. */ + withNodeModules?: boolean; /** Returns whether a scanned directory can be pruned before traversal. */ isDirectoryIgnored?: (directoryPath: string) => boolean; } @@ -47,11 +49,15 @@ const getDirentParentPath = (dirent: Dirent): string => const getDirentPath = (dirent: Dirent, parentPath: string): string => `${parentPath}${parentPath === path.sep ? '' : path.sep}${dirent.name}`; -const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean => +const hasBuiltInIgnoredSegment = ( + cwd: string, + filePath: string, + ignoredDirNames: ReadonlySet, +): boolean => path .relative(cwd, filePath) .split(path.sep) - .some((segment) => alwaysIgnoredNames.has(segment)); + .some((segment) => ignoredDirNames.has(segment)); const findGitRoot = async (cwd: string): Promise => { let directoryPath = cwd; @@ -202,6 +208,7 @@ class GitIgnoreMatcher { const createTraversalOptions = ( gitIgnore: GitIgnoreMatcher, + ignoredDirNames: ReadonlySet, isIncluded?: (filePath: string) => boolean, isDirectoryIgnored?: (directoryPath: string) => boolean, ) => { @@ -212,7 +219,7 @@ const createTraversalOptions = ( followSymlinks: false, ignore: (targetPath: string) => { const isDirectory = directories.delete(targetPath); - if (alwaysIgnoredNames.has(path.basename(targetPath))) { + if (ignoredDirNames.has(path.basename(targetPath))) { return true; } @@ -265,7 +272,11 @@ type ClassifiedPatterns = { negativeGlobs: string[]; }; -const classifyPatterns = async (cwd: string, patterns: string[]): Promise => { +const classifyPatterns = async ( + cwd: string, + patterns: string[], + ignoredDirNames: ReadonlySet, +): Promise => { const entries = await Promise.all( patterns.map(async (pattern): Promise => { if (pattern.startsWith('!')) { @@ -273,7 +284,7 @@ const classifyPatterns = async (cwd: string, patterns: string[]): Promise => { const patterns = inputPatterns?.length ? inputPatterns : ['.']; + const ignoredDirNames = withNodeModules + ? new Set(defaultIgnoredDirNames) + : defaultIgnoredDirNames; + + if (withNodeModules) { + ignoredDirNames.delete('node_modules'); + } + const { files: explicitFiles, directories, globs, negativeGlobs, - } = await classifyPatterns(cwd, patterns); + } = await classifyPatterns(cwd, patterns, ignoredDirNames); const directoryRoots = getOutermostPaths(directories); const globMatchers = globs.map((pattern) => micromatch.matcher(pattern, { dot: true })); const candidates = new Set(explicitFiles); @@ -389,7 +409,10 @@ const discoverFmtPaths = async ({ }; return ( - await readdir(rootPath, createTraversalOptions(gitIgnore, isIncluded, isDirectoryIgnored)) + await readdir( + rootPath, + createTraversalOptions(gitIgnore, ignoredDirNames, isIncluded, isDirectoryIgnored), + ) ).files; }), ); diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 47dc1572..532859be 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -13,12 +13,14 @@ const discoverFmtFiles = async ({ cwd, patterns, ignorePaths, + withNodeModules, config, }: DiscoverFmtFilesOptions): Promise => { const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths }); const candidates = await discoverFmtPaths({ cwd, patterns, + withNodeModules, isDirectoryIgnored: (directoryPath) => isIgnored(directoryPath, true), }); if (candidates.length === 0) { diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index db9b2d66..e93a4bdc 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -58,6 +58,8 @@ interface DiscoverFmtFilesOptions { patterns?: string[]; /** Ignore files resolved from `cwd`; each file's patterns are relative to its own directory. */ ignorePaths?: string[]; + /** Whether files inside node_modules may be discovered. */ + withNodeModules?: boolean; /** Resolved project config applied to discovered files. */ config: ResolvedFmtConfig; } diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 7f568af2..cdd41b7c 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -128,6 +128,21 @@ test('formats the current directory with Prettier defaults', () => { expect(readProjectFile('index.ts')).toBe('const message = "hello";\n'); }); +test('formats files in node_modules with --with-node-modules', () => { + const source = 'const message="hello"'; + writeProjectFile('node_modules/example/index.ts', source); + + const skipped = runFmt(['node_modules/example']); + expect(skipped.status).toBe(2); + expect(readProjectFile('node_modules/example/index.ts')).toBe(source); + + const result = runFmt(['--with-node-modules', 'node_modules/example']); + expect(result.status).toBe(0); + expectWriteSummary(result.stdout, 1, 1); + expect(result.stderr).toBe(''); + expect(readProjectFile('node_modules/example/index.ts')).toBe('const message = "hello";\n'); +}); + test('summarizes write mode when no files change', () => { writeProjectFile('index.ts', 'const message = "hello";\n'); diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index 2df10bdd..9d69d08b 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -13,6 +13,7 @@ Options: --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match + --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers --stdin-filepath Format stdin as if it were saved at -h, --help Display this help message" diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index 9521ccb8..e6870310 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -24,6 +24,7 @@ test('uses write mode by default', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -40,6 +41,7 @@ test.each([ ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -52,6 +54,7 @@ test('configures parallel worker count', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: 3, help: false, }); @@ -75,6 +78,7 @@ test('preserves file paths and globs', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -87,6 +91,7 @@ test('treats arguments after the terminator as paths', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, }); @@ -111,6 +116,10 @@ test.each(['-u', '--ignore-unknown', '--ignoreUnknown'])('parses %s', (option) = expect(parseFmtCLIArgs([option]).ignoreUnknown).toBe(true); }); +test('parses --with-node-modules', () => { + expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true); +}); + test('parses --stdin-filepath', () => { expect(parseFmtCLIArgs(['--stdin-filepath', 'src/index.ts'])).toEqual({ mode: 'write', @@ -118,6 +127,7 @@ test('parses --stdin-filepath', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: undefined, help: false, stdinFilepath: 'src/index.ts', @@ -131,6 +141,7 @@ test('accepts a worker count with --stdin-filepath', () => { ignorePaths: [], ignoreUnknown: false, noErrorOnUnmatchedPattern: false, + withNodeModules: false, maxWorkers: 2, help: false, stdinFilepath: 'index.ts', diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts index a712f61e..02bfbd2d 100644 --- a/packages/rstack/tests/fmt/discoverPaths.test.ts +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -19,6 +19,7 @@ test('discovers non-binary files in stable order and skips hard-ignored paths', writeProjectFile(rootPath, '.jj/internal.js'); const files = await discoverFmtPaths({ cwd: rootPath }); + const filesWithNodeModules = await discoverFmtPaths({ cwd: rootPath, withNodeModules: true }); expect(relativePaths(rootPath, files)).toEqual([ 'a.js', @@ -26,9 +27,35 @@ test('discovers non-binary files in stable order and skips hard-ignored paths', path.join('folder with spaces', 'c.ts'), 'unknown.extension', ]); + expect(relativePaths(rootPath, filesWithNodeModules)).toEqual([ + 'a.js', + 'b.ts', + path.join('folder with spaces', 'c.ts'), + path.join('node_modules', 'package', 'index.js'), + 'unknown.extension', + ]); await expect( discoverFmtPaths({ cwd: rootPath, patterns: ['node_modules/package/index.js'] }), ).resolves.toEqual([]); + await expect( + discoverFmtPaths({ + cwd: rootPath, + patterns: ['node_modules/package/index.js'], + withNodeModules: true, + }), + ).resolves.toEqual([path.join(rootPath, 'node_modules/package/index.js')]); + }); +}); + +test('keeps node_modules excluded by gitignore when built-in exclusion is disabled', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, '.gitignore', 'node_modules/\n'); + writeProjectFile(rootPath, 'node_modules/package/index.js'); + writeProjectFile(rootPath, 'index.js'); + + const files = await discoverFmtPaths({ cwd: rootPath, withNodeModules: true }); + + expect(relativePaths(rootPath, files)).toEqual(['.gitignore', 'index.js']); }); }); diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index b92ac261..9e4f09e1 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -149,6 +149,16 @@ Formatted output is written to stdout and diagnostics to stderr. If the input pa > `--stdin-filepath` cannot be combined with file arguments or with `--write`, `--check`, or `--list-different`. +### `--with-node-modules` + +Process files inside `node_modules`, which `rs fmt` excludes by default: + +```bash +rs fmt --with-node-modules node_modules/example/index.js +``` + +This option only disables the built-in `node_modules` exclusion. Directory and glob scans still follow `.gitignore`, while `ignorePatterns` and `--ignore-path` continue to apply to every input. + ### `--write` Write formatted files in place. This is the default mode, so specifying `--write` is optional: diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index 78fd64f2..a425863e 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -149,6 +149,16 @@ cat src/index.ts | rs fmt --stdin-filepath src/index.ts > `--stdin-filepath` 不能与文件参数或 `--write`、`--check`、`--list-different` 同时使用。 +### `--with-node-modules` + +处理 `node_modules` 中的文件。默认情况下,`rs fmt` 会排除这些文件: + +```bash +rs fmt --with-node-modules node_modules/example/index.js +``` + +此选项只会关闭内置的 `node_modules` 排除规则。目录和 glob 扫描仍然遵循 `.gitignore`,`ignorePatterns` 和 `--ignore-path` 也会继续作用于所有输入。 + ### `--write` 将格式化结果写回文件。这是默认模式,因此可以省略 `--write`: From 616a9ceba61253053e8183c14daa6a9389731e92 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 18:50:48 +0800 Subject: [PATCH 07/22] feat(fmt): add -w alias for write (#198) --- packages/rstack/src/fmt/cli.ts | 4 ++-- packages/rstack/tests/cli/fmt/index.test.ts | 11 +++++++++++ .../rstack/tests/fmt/__snapshots__/cli.test.ts.snap | 2 +- packages/rstack/tests/fmt/cli.test.ts | 1 + website/docs/en/guide/cli/fmt.mdx | 6 ++++++ website/docs/zh/guide/cli/fmt.mdx | 6 ++++++ 6 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 7cada943..3c06ec17 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -29,7 +29,7 @@ ${color.yellow(' $ rs fmt [options] [files/globs...]')} Format files with Prettier. ${color.cyan('Options')}: - --write Write formatted files in place (default) + -w, --write Write formatted files in place (default) --check Check whether files are formatted --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) @@ -57,7 +57,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const { values, positionals } = parseArgs({ args, options: { - write: { type: 'boolean' }, + write: { type: 'boolean', short: 'w' }, check: { type: 'boolean' }, 'list-different': { type: 'boolean' }, 'ignore-path': { type: 'string', multiple: true }, diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index cdd41b7c..30042a04 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -128,6 +128,17 @@ test('formats the current directory with Prettier defaults', () => { expect(readProjectFile('index.ts')).toBe('const message = "hello";\n'); }); +test('accepts -w as an alias for --write', () => { + writeProjectFile('index.ts', 'const message="hello"'); + + const result = runFmt(['-w', 'index.ts']); + + expect(result.status).toBe(0); + expectWriteSummary(result.stdout, 1, 1); + expect(result.stderr).toBe(''); + expect(readProjectFile('index.ts')).toBe('const message = "hello";\n'); +}); + test('formats files in node_modules with --with-node-modules', () => { const source = 'const message="hello"'; writeProjectFile('node_modules/example/index.ts', source); diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index 9d69d08b..c6b1a7d4 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -7,7 +7,7 @@ exports[`provides command help 1`] = ` Format files with Prettier. Options: - --write Write formatted files in place (default) + -w, --write Write formatted files in place (default) --check Check whether files are formatted --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index e6870310..19d5ae88 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -31,6 +31,7 @@ test('uses write mode by default', () => { }); test.each([ + ['-w', 'write'], ['--write', 'write'], ['--check', 'check'], ['--list-different', 'list-different'], diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index 9e4f09e1..bb6edf44 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -167,4 +167,10 @@ Write formatted files in place. This is the default mode, so specifying `--write rs fmt src --write ``` +The short option `-w` is an alias for `--write`: + +```bash +rs fmt -w src +``` + `--write` cannot be combined with `--check` or `--list-different`. diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index a425863e..afe8c572 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -167,4 +167,10 @@ rs fmt --with-node-modules node_modules/example/index.js rs fmt src --write ``` +短选项 `-w` 是 `--write` 的别名: + +```bash +rs fmt -w src +``` + `--write` 不能与 `--check` 或 `--list-different` 同时使用。 From bfbb6963bec226c868c97bb88d612adc5321bb3f Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 18:55:25 +0800 Subject: [PATCH 08/22] perf(fmt): avoid relative resolution on cache hits (#199) --- packages/rstack/src/fmt/discoverPaths.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index acc013d7..6eaf8267 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -153,15 +153,14 @@ class GitIgnoreMatcher { return loading; } - #isDirectoryIgnored( - directoryPath: string, - relativePath = path.relative(this.#rootPath, directoryPath), - ): boolean { + #isDirectoryIgnored(directoryPath: string, relativePath?: string): boolean { const cached = this.#ignoredDirectories.get(directoryPath); if (cached !== undefined) { return cached; } + relativePath ??= path.relative(this.#rootPath, directoryPath); + // Git cannot re-include a path below an ignored directory. const parentPath = path.dirname(directoryPath); const ignored = From 61f257b61efef5a33fda5e15453f3629ff0708b1 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 19:03:02 +0800 Subject: [PATCH 09/22] feat(fmt): add -l alias for list different (#200) --- packages/rstack/src/fmt/cli.ts | 4 ++-- packages/rstack/tests/cli/fmt/index.test.ts | 4 ++-- .../rstack/tests/fmt/__snapshots__/cli.test.ts.snap | 2 +- packages/rstack/tests/fmt/cli.test.ts | 1 + website/docs/en/guide/cli/fmt.mdx | 12 +++++++++--- website/docs/zh/guide/cli/fmt.mdx | 12 +++++++++--- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 3c06ec17..db0e4c94 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -31,7 +31,7 @@ Format files with Prettier. ${color.cyan('Options')}: -w, --write Write formatted files in place (default) --check Check whether files are formatted - --list-different Print paths of unformatted files + -l, --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match @@ -59,7 +59,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { options: { write: { type: 'boolean', short: 'w' }, check: { type: 'boolean' }, - 'list-different': { type: 'boolean' }, + 'list-different': { type: 'boolean', short: 'l' }, 'ignore-path': { type: 'string', multiple: true }, 'ignore-unknown': { type: 'boolean', short: 'u' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 30042a04..166cc6b7 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -337,12 +337,12 @@ test('checks formatting without writing files', () => { expect(formattedResult.stderr).toBe(''); }); -test('lists only paths that differ', () => { +test.each(['-l', '--list-different'])('lists only paths that differ with %s', (option) => { const source = 'const message="hello"'; writeProjectFile('src/index.ts', source); writeProjectFile('src/formatted.ts', 'const formatted = true;\n'); - const result = runFmt(['--list-different', 'src/*.ts']); + const result = runFmt([option, 'src/*.ts']); expect(result.status).toBe(1); expect(result.stdout).toBe('src/index.ts\n'); diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index c6b1a7d4..d0d098a5 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -9,7 +9,7 @@ Format files with Prettier. Options: -w, --write Write formatted files in place (default) --check Check whether files are formatted - --list-different Print paths of unformatted files + -l, --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files --no-error-on-unmatched-pattern Do not error when no files match diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index 19d5ae88..4512e433 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -34,6 +34,7 @@ test.each([ ['-w', 'write'], ['--write', 'write'], ['--check', 'check'], + ['-l', 'list-different'], ['--list-different', 'list-different'], ] as const)('parses %s mode', (option, mode) => { expect(parseFmtCLIArgs([option])).toEqual({ diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index bb6edf44..0bb50761 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -36,7 +36,7 @@ rs format Check whether files are formatted without changing them. The output lists files with formatting issues and includes a human-friendly summary, making this option useful in CI: ```bash -rs fmt . --check +rs fmt --check ``` `--check` cannot be combined with `--write` or `--list-different`. @@ -110,7 +110,13 @@ When used with `--stdin-filepath`, unsupported input is skipped without writing Print the paths of unformatted files without the summary produced by `--check`. This is useful when another command needs to consume the output: ```bash -rs fmt . --list-different +rs fmt --list-different +``` + +The short option `-l` is an alias for `--list-different`: + +```bash +rs fmt -l ``` The option uses the same exit codes as `--check` and cannot be combined with `--write` or `--check`. @@ -132,7 +138,7 @@ For example, a pre-commit script may always run `rs fmt`, even when the staged c Set the maximum number of formatting workers to a positive integer: ```bash -rs fmt . --parallel-workers 4 +rs fmt --parallel-workers 4 ``` When this option is omitted, `rs fmt` automatically chooses up to eight workers based on the available CPU parallelism and the number of matched files. Set a lower value to limit CPU or memory usage in constrained environments. diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index afe8c572..290e6158 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -36,7 +36,7 @@ rs format 检查文件是否已格式化,但不修改文件。输出会列出存在格式问题的文件,并提供便于阅读的汇总信息,因此适合在 CI 中使用: ```bash -rs fmt . --check +rs fmt --check ``` `--check` 不能与 `--write` 或 `--list-different` 同时使用。 @@ -110,7 +110,13 @@ rs fmt -u 输出未格式化文件的路径,但不提供 `--check` 的汇总信息。需要将结果交给其他命令处理时,可以使用此选项: ```bash -rs fmt . --list-different +rs fmt --list-different +``` + +短选项 `-l` 是 `--list-different` 的别名: + +```bash +rs fmt -l ``` 此选项与 `--check` 使用相同的退出状态码,且不能与 `--write` 或 `--check` 同时使用。 @@ -132,7 +138,7 @@ rs fmt --no-error-on-unmatched-pattern 'src/**/*.ts' 将格式化 worker 的最大数量设置为正整数: ```bash -rs fmt . --parallel-workers 4 +rs fmt --parallel-workers 4 ``` 省略此选项时,`rs fmt` 会根据可用的 CPU 并行度和匹配的文件数量,自动选择最多 8 个 worker。在资源受限的环境中,可以设置较小的值来限制 CPU 或内存用量。 From d08c2a3faa9e29e07bf6341c533b43371c0876d9 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 19:16:25 +0800 Subject: [PATCH 10/22] perf(fmt): avoid relative resolution in gitignore matching (#201) --- packages/rstack/src/fmt/discoverPaths.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 6eaf8267..1b587bbb 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -77,12 +77,14 @@ const findGitRoot = async (cwd: string): Promise => { class GitIgnoreMatcher { readonly #rootPath: string; + readonly #rootPrefix: string; readonly #matchers = new Map>(); readonly #loads = new Map>(); readonly #ignoredDirectories = new Map(); private constructor(rootPath: string) { this.#rootPath = rootPath; + this.#rootPrefix = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; } static async create(cwd: string): Promise { @@ -120,7 +122,12 @@ class GitIgnoreMatcher { return false; } - const relativePath = path.relative(this.#rootPath, filePath); + const relativePath = + filePath === this.#rootPath + ? '' + : filePath.startsWith(this.#rootPrefix) + ? filePath.slice(this.#rootPrefix.length) + : path.relative(this.#rootPath, filePath); if (relativePath === '' || !isRelativePathInside(relativePath)) { return false; } From dd75423b00adde7ec2388360d91971be668d04b8 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 19:20:27 +0800 Subject: [PATCH 11/22] chore(deps): upgrade Rslint to v0.7.3 (#202) --- pnpm-lock.yaml | 88 +++++++++++++++++++++------------------------ pnpm-workspace.yaml | 2 +- 2 files changed, 42 insertions(+), 48 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4c1e082..7886b530 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ catalogs: specifier: ~1.0.0-beta.1 version: 1.0.0-beta.1 '@rslint/core': - specifier: ~0.7.2 - version: 0.7.2 + specifier: ~0.7.3 + version: 0.7.3 '@rspress/core': specifier: ^2.0.19 version: 2.0.19 @@ -332,7 +332,7 @@ importers: version: 1.0.0-beta.1(typescript@7.0.2) '@rslint/core': specifier: 'catalog:' - version: 0.7.2 + version: 0.7.3 '@rstest/core': specifier: 'catalog:' version: 0.11.5(happy-dom@20.11.1) @@ -676,56 +676,56 @@ packages: typescript: optional: true - '@rslint/core@0.7.2': - resolution: {integrity: sha512-jzSu6fMEWnuNEIZZk016z5Zk1AHYhNdfsCkvVvYfcduGyEAOo4QZDx+eXJ8R2bJqunAXLJPMx3JykXTjxyGjlQ==} + '@rslint/core@0.7.3': + resolution: {integrity: sha512-vRg6dOzyTie/fMOfvQ+4v3CoZ+IMyAfWyFC9bYf2QiKOV+csE/JHTV+yvh68YAEZvsDuQr35+8yerJFYLaFMPw==} hasBin: true peerDependencies: - jiti: ^2.0.0 + jiti: ^2.7.0 peerDependenciesMeta: jiti: optional: true - '@rslint/native-darwin-arm64@0.7.2': - resolution: {integrity: sha512-Q7Nx26S7O1zlELKNIyi3+ZBn6s+ZrGFmyMkqWT2UsXsq9jW3sUGJG44/BcvAFXtFYg7ONUl7LDYakz/VP7DzXQ==} + '@rslint/native-darwin-arm64@0.7.3': + resolution: {integrity: sha512-BJOWoF5lD+6Kyigxfo38rXviS5cvHgZwQJ2zOjAal2Xiv2mn8Il88KGs8M/KwWNryFcQPGp5wjk6i6uPHwYb1w==} cpu: [arm64] os: [darwin] - '@rslint/native-darwin-x64@0.7.2': - resolution: {integrity: sha512-ONbEKiPd/StrV+/enPMJz60/+oJCiuVK9cbMpymWjAv1qDNCiuTNIqb5RUc4OHxWy7QZ9LWbVw4X/5XcJf0ebQ==} + '@rslint/native-darwin-x64@0.7.3': + resolution: {integrity: sha512-3WIJocfinQs9YkzqgNXBIQNOylpz4IUI6LPzARE4tUJz9GgZF9Q7IEuscpVgkVUkh/P+Pr4CUplivggzXiBFBw==} cpu: [x64] os: [darwin] - '@rslint/native-linux-arm64-gnu@0.7.2': - resolution: {integrity: sha512-06C0QJF6gJ/VkLPBw6+SauH91PnUM83Kd7tBIqU5QP11q3iIK+aPFGMbSrKsKu6/+yVig424Z4nSxcQ2MzCmag==} + '@rslint/native-linux-arm64-gnu@0.7.3': + resolution: {integrity: sha512-lLKQ+A/GiTvzOjtiK0euWul40nmQziR925JXznqcXgT0g1UdU7FwWdcy6l/UCQW8aVgzycV8yJMdaKmz+6H7Vg==} cpu: [arm64] os: [linux] libc: [glibc] - '@rslint/native-linux-arm64-musl@0.7.2': - resolution: {integrity: sha512-KpgwL3sgRVNx3LciBcfmRxxIymuQKBo3vinEewHWdll+WkRlS08Ow1XhSu2YIrenOYQ5cKSD26PW57AUE8s2zA==} + '@rslint/native-linux-arm64-musl@0.7.3': + resolution: {integrity: sha512-i6jVTIii8eIHFfb/UNMNRvZGPiW/yH4ZgfX/+77kxAnAYHm0wx2qXakW9M8LDTdtCz1UBqV0UL7NB4gBQyDPwQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rslint/native-linux-x64-gnu@0.7.2': - resolution: {integrity: sha512-TOTVGJvFW2uxMthV3g05HNik0BWUE8gabZp5mYiDF4dc+yFihqWw1kRO//4hZyd4m0CPkRpsBL89UCwAX6lBzA==} + '@rslint/native-linux-x64-gnu@0.7.3': + resolution: {integrity: sha512-Qta8uiB4c3zuLK/1pCgx9DzhtCvQ/i31TDoNSA3WpY17Tsa82CGXho1l6bCA1/9dbuOz5lwCsYALQLVe8/o/rQ==} cpu: [x64] os: [linux] libc: [glibc] - '@rslint/native-linux-x64-musl@0.7.2': - resolution: {integrity: sha512-rn4g1i8VVZeVnc/Qa1IJVvX0e4XQncB144CTwHeFnC2V8aMBL6kmfvBox2mL59nPRTlILf4GAMOMlD3tSD5N5Q==} + '@rslint/native-linux-x64-musl@0.7.3': + resolution: {integrity: sha512-urpqLlJISIFAtiXCGGwRZgfnSYpFDi5lzk6ibSgBPvJmv94yhxMpDoutpHTA3UyVDeH33axYGRonImLf7UfBSA==} cpu: [x64] os: [linux] libc: [musl] - '@rslint/native-win32-arm64-msvc@0.7.2': - resolution: {integrity: sha512-j2kdE1+3TdXhjtmu+b9lWJThSaT3SZKcMa5dlEy20+bDwTCBmuhO6lR79/4BllXz8/avP8W0YmkfORitoVPxrA==} + '@rslint/native-win32-arm64-msvc@0.7.3': + resolution: {integrity: sha512-jcxjgUPMl725p0wjXLPIPMeARK41FwHXiWVmg4AqIHJBfpXpwM+xozVn4Dd5GL3TO38zjmP881bKOvtPwnFl0Q==} cpu: [arm64] os: [win32] - '@rslint/native-win32-x64-msvc@0.7.2': - resolution: {integrity: sha512-qOXNWTn4Q9gf6/GCmJlJt5heVD+WIdbVSLRb2KbJLt055ZuVQV40Md8NkUSWF94j/J9+1d21/UoOvKDNt144fg==} + '@rslint/native-win32-x64-msvc@0.7.3': + resolution: {integrity: sha512-R87nmvTUZry9DPF8Cz6TGLLAikIyUxHDPwzonrYaJ0kTLeJAgDMfVIY1s5pUxF3heQD6G2QXjhpU/uPwqnvmzg==} cpu: [x64] os: [win32] @@ -1821,10 +1821,6 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -2519,41 +2515,41 @@ snapshots: - '@module-federation/runtime-tools' - core-js - '@rslint/core@0.7.2': + '@rslint/core@0.7.3': dependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: - '@rslint/native-darwin-arm64': 0.7.2 - '@rslint/native-darwin-x64': 0.7.2 - '@rslint/native-linux-arm64-gnu': 0.7.2 - '@rslint/native-linux-arm64-musl': 0.7.2 - '@rslint/native-linux-x64-gnu': 0.7.2 - '@rslint/native-linux-x64-musl': 0.7.2 - '@rslint/native-win32-arm64-msvc': 0.7.2 - '@rslint/native-win32-x64-msvc': 0.7.2 - - '@rslint/native-darwin-arm64@0.7.2': + '@rslint/native-darwin-arm64': 0.7.3 + '@rslint/native-darwin-x64': 0.7.3 + '@rslint/native-linux-arm64-gnu': 0.7.3 + '@rslint/native-linux-arm64-musl': 0.7.3 + '@rslint/native-linux-x64-gnu': 0.7.3 + '@rslint/native-linux-x64-musl': 0.7.3 + '@rslint/native-win32-arm64-msvc': 0.7.3 + '@rslint/native-win32-x64-msvc': 0.7.3 + + '@rslint/native-darwin-arm64@0.7.3': optional: true - '@rslint/native-darwin-x64@0.7.2': + '@rslint/native-darwin-x64@0.7.3': optional: true - '@rslint/native-linux-arm64-gnu@0.7.2': + '@rslint/native-linux-arm64-gnu@0.7.3': optional: true - '@rslint/native-linux-arm64-musl@0.7.2': + '@rslint/native-linux-arm64-musl@0.7.3': optional: true - '@rslint/native-linux-x64-gnu@0.7.2': + '@rslint/native-linux-x64-gnu@0.7.3': optional: true - '@rslint/native-linux-x64-musl@0.7.2': + '@rslint/native-linux-x64-musl@0.7.3': optional: true - '@rslint/native-win32-arm64-msvc@0.7.2': + '@rslint/native-win32-arm64-msvc@0.7.3': optional: true - '@rslint/native-win32-x64-msvc@0.7.2': + '@rslint/native-win32-x64-msvc@0.7.3': optional: true '@rspack/binding-darwin-arm64@2.1.8': @@ -3902,8 +3898,6 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} - picomatch@4.0.5: {} postcss@8.5.19: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 555ddb92..97dec285 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,7 +16,7 @@ catalog: '@rsbuild/plugin-react': '^2.1.0' '@rsbuild/plugin-sass': '^2.0.1' '@rslib/core': '~1.0.0-beta.1' - '@rslint/core': '~0.7.2' + '@rslint/core': '~0.7.3' '@rspress/core': '^2.0.19' '@rspress/plugin-client-redirects': '^2.0.19' '@rspress/plugin-sitemap': '^2.0.19' From ba09df7ffaedfc328e2f17a4e51096362dd2f9fe Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 21:08:24 +0800 Subject: [PATCH 12/22] chore: align VS Code formatting with Rstack config (#203) --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9f7026ef..a8bf25da 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,9 @@ }, "mdx.validate.validateFileLinks": "ignore", "editor.defaultFormatter": "esbenp.prettier-vscode", + // Temporary workaround until we switch to the Rstack VS Code extension. + "prettier.printWidth": 100, + "prettier.singleQuote": true, "js/ts.tsdk.path": "node_modules/typescript/lib", "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" From 0fa610ff6cb91cd72496430948a9230e6e23ddd8 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 21:26:56 +0800 Subject: [PATCH 13/22] perf(fmt): avoid duplicate binary path checks (#204) --- packages/rstack/src/fmt/discoverPaths.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 1b587bbb..278f244a 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -296,7 +296,7 @@ const classifyPatterns = async ( const stats = await lstatSafe(filePath); if (stats?.isFile()) { - return { kind: 'file', value: filePath }; + return isBinaryPath(filePath) ? undefined : { kind: 'file', value: filePath }; } if (stats?.isDirectory()) { return { kind: 'directory', value: filePath }; @@ -436,10 +436,6 @@ const discoverFmtPaths = async ({ 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))) { From cfaead56e724db81508f9d9bc6cb4d15145a1bce Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 22:25:57 +0800 Subject: [PATCH 14/22] perf(fmt): use tiny-readdir context (#206) --- packages/rstack/src/fmt/discoverPaths.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 278f244a..c8370c71 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import ignore from 'ignore'; import isBinaryPath from 'is-binary-path'; import micromatch from 'micromatch'; -import readdir, { type Dirent } from 'tiny-readdir'; +import readdir, { type Dirent, type DirentLike } from 'tiny-readdir'; const defaultIgnoredDirNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); @@ -45,10 +45,6 @@ const toPosixPath = (filePath: string): string => 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 hasBuiltInIgnoredSegment = ( cwd: string, filePath: string, @@ -218,18 +214,16 @@ const createTraversalOptions = ( isIncluded?: (filePath: string) => boolean, isDirectoryIgnored?: (directoryPath: 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 (ignoredDirNames.has(path.basename(targetPath))) { + ignore: (targetPath: string, targetContext: DirentLike) => { + // With symlink following disabled, tiny-readdir always provides a Dirent here. + const dirent = targetContext as Dirent; + if (ignoredDirNames.has(dirent.name)) { return true; } - if (isDirectory) { + if (dirent.isDirectory()) { return gitIgnore.isIgnored(targetPath, true) || isDirectoryIgnored?.(targetPath) === true; } @@ -244,9 +238,6 @@ const createTraversalOptions = ( let hasGitIgnore = false; for (const dirent of dirents) { - if (dirent.isDirectory()) { - directories.add(getDirentPath(dirent, parentPath)); - } if (dirent.name === '.gitignore') { hasGitIgnore = true; } From fc8cfac255737bd9f50ba41933e7b6ab23c90ad0 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Wed, 5 Aug 2026 22:26:08 +0800 Subject: [PATCH 15/22] fix(setup): provide Node fallback for Git hooks (#205) --- packages/rstack/src/setup/hooks.ts | 30 ++++++++++++++--- packages/rstack/tests/setup/hooks.test.ts | 40 ++++++++++++++++++++--- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index 4dc68daa..518d4789 100644 --- a/packages/rstack/src/setup/hooks.ts +++ b/packages/rstack/src/setup/hooks.ts @@ -17,9 +17,22 @@ const hookNames = [ 'pre-auto-gc', ]; +// Git for Windows runs hooks in a POSIX shell, where drive-letter paths need +// their Git Bash form to avoid treating the drive colon as a PATH separator. +const quoteShellPath = (value: string): string => { + const shellPath = + process.platform === 'win32' + ? value + .replaceAll('\\', '/') + .replace(/^([A-Za-z]):\//u, (_, drive: string) => `/${drive.toLowerCase()}/`) + : value; + + return `'${shellPath.replaceAll("'", `'"'"'`)}'`; +}; + // Generated shims live in `/_`. When a shim sources this // dispatcher, `$0` still points to the shim, so the user hook is one level up. -const dispatcher = `#!/usr/bin/env sh +const createDispatcher = (nodeExecutable: string): string => `#!/usr/bin/env sh name=$(basename "$0") dir=$(dirname "$(dirname "$0")") @@ -33,7 +46,14 @@ init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh" [ "\${RSTACK_HOOKS-}" = "0" ] && exit 0 [ "\${RSTACK_HOOKS-}" = "2" ] && set -x -export PATH="node_modules/.bin:$PATH" +# Fall back to the Node.js executable that ran rs setup when GUI clients omit +# it from PATH. Keep an existing Node.js environment ahead of this fallback. +node_fallback=${quoteShellPath(nodeExecutable)} +if ! command -v node >/dev/null 2>&1 && [ -x "$node_fallback" ]; then + PATH="\${PATH:+$PATH:}\${node_fallback%/*}" +fi + +export PATH="node_modules/.bin\${PATH:+:$PATH}" code=0 sh -e "$hook" "$@" || code=$? @@ -49,8 +69,10 @@ const shim = `#!/usr/bin/env sh . "$(dirname "$0")/runner" `; -export const createHookFiles = (): Record => { - const files: Record = { runner: dispatcher }; +export const createHookFiles = ( + nodeExecutable: string = process.execPath, +): Record => { + const files: Record = { runner: createDispatcher(nodeExecutable) }; for (const name of hookNames) { files[name] = shim; diff --git a/packages/rstack/tests/setup/hooks.test.ts b/packages/rstack/tests/setup/hooks.test.ts index 9c81021a..882e1b60 100644 --- a/packages/rstack/tests/setup/hooks.test.ts +++ b/packages/rstack/tests/setup/hooks.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from 'rstack/test'; import { createHookFiles } from '../../src/setup/hooks.ts'; @@ -28,16 +28,33 @@ test('generates the dispatcher and all client-side Git hook shims', () => { expect(new Set(Object.values(shims)).size).toBe(1); }); +test.runIf(process.platform === 'win32')('converts Windows Node paths', () => { + const { runner } = createHookFiles(String.raw`C:\Program Files\nodejs\node.exe`); + + expect(runner).toContain("node_fallback='/c/Program Files/nodejs/node.exe'"); +}); + +test.runIf(process.platform !== 'win32')('preserves backslashes in POSIX Node paths', () => { + const nodeExecutable = String.raw`/opt/node\24/bin/node`; + const { runner } = createHookFiles(nodeExecutable); + + expect(runner).toContain(`node_fallback='${nodeExecutable}'`); +}); + test.runIf(process.platform !== 'win32')('runs generated hooks', () => { withDirectory((directory) => { - const hooksDirectory = path.join(directory, 'hooks with spaces'); + const hooksDirectory = path.join(directory, "hooks with ' quotes"); const generatedDirectory = path.join(hooksDirectory, '_'); const generatedHook = path.join(generatedDirectory, 'pre-commit'); const userHook = path.join(hooksDirectory, 'pre-commit'); - const files = createHookFiles(); + const fallbackNode = path.join(hooksDirectory, 'node'); + const configDirectory = path.join(directory, 'runtime config'); + const runtimeDirectory = path.join(configDirectory, 'rstack'); + const init = path.join(runtimeDirectory, 'hooks-init.sh'); + const files = createHookFiles(fallbackNode); const env: NodeJS.ProcessEnv = { ...process.env, - XDG_CONFIG_HOME: path.join(directory, 'config'), + XDG_CONFIG_HOME: configDirectory, }; mkdirSync(generatedDirectory, { recursive: true }); @@ -71,5 +88,20 @@ printf 'unreachable\\n' expect(errexitResult.status).toBe(1); expect(errexitResult.stdout).toBe('Rstack - pre-commit hook failed (code 1)\n'); + + mkdirSync(runtimeDirectory, { recursive: true }); + writeFileSync(init, `export PATH="${runtimeDirectory}"\n`); + writeFileSync(userHook, 'command -v node\n'); + symlinkSync('/bin/sh', path.join(runtimeDirectory, 'sh')); + symlinkSync('/bin/sh', fallbackNode); + + const fallbackResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env }); + expect(fallbackResult.stdout).toBe(`${fallbackNode}\n`); + + const activeNode = path.join(runtimeDirectory, 'node'); + symlinkSync('/bin/sh', activeNode); + + const activeResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env }); + expect(activeResult.stdout).toBe(`${activeNode}\n`); }); }); From c62e4d9848bb2657a38e426fbd31ee09278ec43e Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 10:14:31 +0800 Subject: [PATCH 16/22] perf(fmt): optimize discovery path resolution (#207) --- packages/rstack/src/fmt/discoverPaths.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index c8370c71..afd70fe9 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -38,6 +38,19 @@ const isRelativePathInside = (relativePath: string): boolean => const isPathInside = (rootPath: string, filePath: string): boolean => isRelativePathInside(path.relative(rootPath, filePath)); +type RelativePathResolver = (filePath: string) => string; + +const createRelativePathResolver = (rootPath: string): RelativePathResolver => { + const rootPrefix = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; + + return (filePath) => + filePath === rootPath + ? '' + : filePath.startsWith(rootPrefix) + ? filePath.slice(rootPrefix.length) + : path.relative(rootPath, filePath); +}; + const toPosixPath = (filePath: string): string => path.sep === '\\' ? filePath.replaceAll('\\', '/') : filePath; @@ -358,6 +371,7 @@ const discoverFmtPaths = async ({ isDirectoryIgnored, }: DiscoverFmtPathsOptions): Promise => { const patterns = inputPatterns?.length ? inputPatterns : ['.']; + const resolveRelativePath = createRelativePathResolver(cwd); const ignoredDirNames = withNodeModules ? new Set(defaultIgnoredDirNames) : defaultIgnoredDirNames; @@ -401,7 +415,7 @@ const discoverFmtPaths = async ({ return true; } - const relativePath = toPosixPath(path.relative(cwd, filePath)); + const relativePath = toPosixPath(resolveRelativePath(filePath)); return globMatchers.some((matches) => matches(relativePath)); }; @@ -428,7 +442,7 @@ const discoverFmtPaths = async ({ for (const filePath of candidates) { if (negativeGlobMatchers.length) { - const relativePath = toPosixPath(path.relative(cwd, filePath)); + const relativePath = toPosixPath(resolveRelativePath(filePath)); if (negativeGlobMatchers.some((matches) => matches(relativePath))) { continue; } From 09e5b62041340e79cd73fe9d62c5b1bd7aca46dc Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 10:25:26 +0800 Subject: [PATCH 17/22] refactor(fmt): reuse relative path resolver (#208) --- packages/rstack/src/fmt/discoverPaths.ts | 33 +++++-------------- packages/rstack/src/fmt/ignore.ts | 7 ++-- packages/rstack/src/fmt/relativePath.ts | 17 ++++++++++ .../rstack/tests/fmt/relativePath.test.ts | 21 ++++++++++++ 4 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 packages/rstack/src/fmt/relativePath.ts create mode 100644 packages/rstack/tests/fmt/relativePath.test.ts diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index afd70fe9..c8f25d9a 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -4,6 +4,7 @@ import ignore from 'ignore'; import isBinaryPath from 'is-binary-path'; import micromatch from 'micromatch'; import readdir, { type Dirent, type DirentLike } from 'tiny-readdir'; +import { createRelativePathResolver, type RelativePathResolver } from './relativePath.ts'; const defaultIgnoredDirNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); @@ -38,19 +39,6 @@ const isRelativePathInside = (relativePath: string): boolean => const isPathInside = (rootPath: string, filePath: string): boolean => isRelativePathInside(path.relative(rootPath, filePath)); -type RelativePathResolver = (filePath: string) => string; - -const createRelativePathResolver = (rootPath: string): RelativePathResolver => { - const rootPrefix = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; - - return (filePath) => - filePath === rootPath - ? '' - : filePath.startsWith(rootPrefix) - ? filePath.slice(rootPrefix.length) - : path.relative(rootPath, filePath); -}; - const toPosixPath = (filePath: string): string => path.sep === '\\' ? filePath.replaceAll('\\', '/') : filePath; @@ -86,14 +74,14 @@ const findGitRoot = async (cwd: string): Promise => { class GitIgnoreMatcher { readonly #rootPath: string; - readonly #rootPrefix: string; + readonly #resolveRelativePath: RelativePathResolver; readonly #matchers = new Map>(); readonly #loads = new Map>(); readonly #ignoredDirectories = new Map(); private constructor(rootPath: string) { this.#rootPath = rootPath; - this.#rootPrefix = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; + this.#resolveRelativePath = createRelativePathResolver(rootPath); } static async create(cwd: string): Promise { @@ -103,11 +91,11 @@ class GitIgnoreMatcher { } async loadThrough(directoryPath: string): Promise { - if (!isPathInside(this.#rootPath, directoryPath)) { + const relativePath = this.#resolveRelativePath(directoryPath); + if (!isRelativePathInside(relativePath)) { return; } - const relativePath = path.relative(this.#rootPath, directoryPath); const segments = relativePath ? relativePath.split(path.sep) : []; const loads = [this.#load(this.#rootPath)]; let currentPath = this.#rootPath; @@ -121,7 +109,7 @@ class GitIgnoreMatcher { } async load(directoryPath: string): Promise { - if (isPathInside(this.#rootPath, directoryPath)) { + if (isRelativePathInside(this.#resolveRelativePath(directoryPath))) { await this.#load(directoryPath); } } @@ -131,12 +119,7 @@ class GitIgnoreMatcher { return false; } - const relativePath = - filePath === this.#rootPath - ? '' - : filePath.startsWith(this.#rootPrefix) - ? filePath.slice(this.#rootPrefix.length) - : path.relative(this.#rootPath, filePath); + const relativePath = this.#resolveRelativePath(filePath); if (relativePath === '' || !isRelativePathInside(relativePath)) { return false; } @@ -175,7 +158,7 @@ class GitIgnoreMatcher { return cached; } - relativePath ??= path.relative(this.#rootPath, directoryPath); + relativePath ??= this.#resolveRelativePath(directoryPath); // Git cannot re-include a path below an ignored directory. const parentPath = path.dirname(directoryPath); diff --git a/packages/rstack/src/fmt/ignore.ts b/packages/rstack/src/fmt/ignore.ts index 1405de29..33daf474 100644 --- a/packages/rstack/src/fmt/ignore.ts +++ b/packages/rstack/src/fmt/ignore.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import createIgnore from 'ignore'; +import { createRelativePathResolver } from './relativePath.ts'; import type { ResolvedFmtConfig } from './types.ts'; /** @@ -28,12 +29,10 @@ const createDefaultIgnoreMatcher = (): IgnoreMatcher => { const createPatternMatcher = (rootPath: string, patterns: string): IgnoreMatcher => { const matcher = createIgnore({ allowRelativePaths: true }).add(patterns); - const rootPrefix = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; + const resolveRelativePath = createRelativePathResolver(rootPath); return (filePath, isDirectory = false) => { - const relativePath = filePath.startsWith(rootPrefix) - ? filePath.slice(rootPrefix.length) - : path.relative(rootPath, filePath); + const relativePath = resolveRelativePath(filePath); if (relativePath === '') { return false; } diff --git a/packages/rstack/src/fmt/relativePath.ts b/packages/rstack/src/fmt/relativePath.ts new file mode 100644 index 00000000..9e5b1d09 --- /dev/null +++ b/packages/rstack/src/fmt/relativePath.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; + +type RelativePathResolver = (filePath: string) => string; + +const createRelativePathResolver = (rootPath: string): RelativePathResolver => { + const rootPrefix = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; + + return (filePath) => + filePath === rootPath + ? '' + : filePath.startsWith(rootPrefix) + ? filePath.slice(rootPrefix.length) + : path.relative(rootPath, filePath); +}; + +export { createRelativePathResolver }; +export type { RelativePathResolver }; diff --git a/packages/rstack/tests/fmt/relativePath.test.ts b/packages/rstack/tests/fmt/relativePath.test.ts new file mode 100644 index 00000000..5b07d90a --- /dev/null +++ b/packages/rstack/tests/fmt/relativePath.test.ts @@ -0,0 +1,21 @@ +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { createRelativePathResolver } from '../../src/fmt/relativePath.ts'; + +const rootPath = path.join(import.meta.dirname, 'project'); + +test('resolves paths relative to a fixed root', () => { + const resolveRelativePath = createRelativePathResolver(rootPath); + + expect(resolveRelativePath(rootPath)).toBe(''); + expect(resolveRelativePath(path.join(rootPath, 'src/index.ts'))).toBe( + path.join('src', 'index.ts'), + ); +}); + +test('falls back for paths outside the fixed root', () => { + const resolveRelativePath = createRelativePathResolver(rootPath); + const siblingPath = path.join(`${rootPath}-other`, 'index.ts'); + + expect(resolveRelativePath(siblingPath)).toBe(path.relative(rootPath, siblingPath)); +}); From 680bb38426286722bde9ef080015b14effcef0a2 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 10:49:14 +0800 Subject: [PATCH 18/22] fix(setup): avoid redundant hooks config writes (#209) --- packages/rstack/src/setup/install.ts | 8 +++++++- packages/rstack/tests/setup/install.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/rstack/src/setup/install.ts b/packages/rstack/src/setup/install.ts index dabab0d1..e73fdef9 100644 --- a/packages/rstack/src/setup/install.ts +++ b/packages/rstack/src/setup/install.ts @@ -126,9 +126,10 @@ export const installHooks = ({ const directory = path.join(cwd, resolvedDir, '_'); const files = Object.entries(createHookFiles()); + const hooksPathMatches = path.resolve(cwd, configuredHooksPath) === directory; // Skip all writes only when the config, generated content, and executable modes match. const unchanged = - path.resolve(cwd, configuredHooksPath) === directory && + hooksPathMatches && isCurrentFile(path.join(directory, '.gitignore'), gitignore) && files.every(([name, content]) => isCurrentFile(path.join(directory, name), content, true)); @@ -151,6 +152,11 @@ export const installHooks = ({ return fail('write-failed', `Failed to write Git hook files: ${message}`); } + // Avoid rewriting .git/config when only the generated files needed repair. + if (hooksPathMatches) { + return { status: 'installed', hooksPath }; + } + // Point Git at the generated directory only after every runtime file is ready. const configured = runGit(cwd, ['config', '--local', 'core.hooksPath', hooksPath]); if (configured.error || configured.status === null) { diff --git a/packages/rstack/tests/setup/install.test.ts b/packages/rstack/tests/setup/install.test.ts index 643000b9..44aef2a6 100644 --- a/packages/rstack/tests/setup/install.test.ts +++ b/packages/rstack/tests/setup/install.test.ts @@ -50,6 +50,18 @@ test.runIf(process.platform !== 'win32')('restores executable mode on existing s }); }); +test('repairs generated files without rewriting an unchanged hooksPath', () => { + withRepository((cwd) => { + expect(installHooks({ cwd }).status).toBe('installed'); + const runner = path.join(cwd, hooksPath, 'runner'); + writeFileSync(runner, 'stale\n'); + writeFileSync(path.join(cwd, '.git', 'config.lock'), 'locked'); + + expect(installHooks({ cwd })).toEqual({ status: 'installed', hooksPath }); + expect(readFileSync(runner, 'utf8')).toBe(createHookFiles().runner); + }); +}); + test('skips non-Git directories without creating files', () => { withDirectory((cwd) => { expect(installHooks({ cwd })).toEqual({ From 0e8f7b3c0192b001c77417b67c15d5ca314306a8 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 10:49:39 +0800 Subject: [PATCH 19/22] perf(fmt): reuse config options resolver (#210) --- packages/rstack/src/fmt/config.ts | 39 ++++++++++++++---------- packages/rstack/src/fmt/discovery.ts | 14 ++++++--- packages/rstack/src/fmt/stdin.ts | 3 +- packages/rstack/tests/fmt/config.test.ts | 22 ++++++++++--- 4 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index 949116ab..86ba749f 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -1,5 +1,6 @@ -import { dirname, relative } from 'node:path'; +import { dirname } from 'node:path'; import micromatch from 'micromatch'; +import { createRelativePathResolver } from './relativePath.ts'; import type { FmtConfig, FmtConfigDefinition, @@ -14,6 +15,7 @@ type ResolveFmtConfigOptions = { }; type PathMatcher = (filePath: string) => boolean; +type FmtOptionsResolver = (filePath: string) => ResolvedFmtOptions; const neverMatches: PathMatcher = () => false; @@ -87,26 +89,30 @@ const normalizeFmtConfig = (config: FmtConfig | undefined, rootPath: string): Re }; }; -/** Applies matching overrides to the shared formatter options. */ -const resolveFmtOptions = (filePath: string, config: ResolvedFmtConfig): ResolvedFmtOptions => { +/** Creates a reusable resolver for applying per-file formatter overrides. */ +const createFmtOptionsResolver = (config: ResolvedFmtConfig): FmtOptionsResolver => { if (config.overrides.length === 0) { - return config.baseOptions; + return () => config.baseOptions; } - let options = config.baseOptions; - const relativeFilePath = relative(config.rootPath, filePath); + const resolveRelativePath = createRelativePathResolver(config.rootPath); - for (const override of config.overrides) { - if (!override.options || !override.matches(relativeFilePath)) { - continue; - } - if (options === config.baseOptions) { - options = { ...options }; + return (filePath) => { + let options = config.baseOptions; + const relativeFilePath = resolveRelativePath(filePath); + + for (const override of config.overrides) { + if (!override.options || !override.matches(relativeFilePath)) { + continue; + } + if (options === config.baseOptions) { + options = { ...options }; + } + Object.assign(options, override.options); } - Object.assign(options, override.options); - } - return options; + return options; + }; }; /** Resolves a formatter config definition and its project root. */ @@ -121,4 +127,5 @@ const resolveFmtConfig = async ({ return normalizeFmtConfig(config, rootPath); }; -export { normalizeFmtConfig, resolveFmtConfig, resolveFmtOptions }; +export { createFmtOptionsResolver, normalizeFmtConfig, resolveFmtConfig }; +export type { FmtOptionsResolver }; diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 532859be..bacd7553 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -1,11 +1,14 @@ -import { resolveFmtOptions } from './config.ts'; +import { createFmtOptionsResolver, type FmtOptionsResolver } from './config.ts'; import { discoverFmtPaths } from './discoverPaths.ts'; import { createIgnoreMatcher } from './ignore.ts'; -import type { DiscoverFmtFilesOptions, FmtFileRequest, ResolvedFmtConfig } from './types.ts'; +import type { DiscoverFmtFilesOptions, FmtFileRequest } from './types.ts'; -const createFileRequest = (filePath: string, config: ResolvedFmtConfig): FmtFileRequest => ({ +const createFileRequest = ( + filePath: string, + resolveOptions: FmtOptionsResolver, +): FmtFileRequest => ({ path: filePath, - options: resolveFmtOptions(filePath, config), + options: resolveOptions(filePath), }); /** Discovers worker-ready files without automatically reading Prettier config or ignore files. */ @@ -28,7 +31,8 @@ const discoverFmtFiles = async ({ } const filePaths = candidates.filter((filePath) => !isIgnored(filePath)); - const files = filePaths.map((filePath) => createFileRequest(filePath, config)); + const resolveOptions = createFmtOptionsResolver(config); + const files = filePaths.map((filePath) => createFileRequest(filePath, resolveOptions)); if (!files.some((file) => file.options.plugins?.length)) { return files; } diff --git a/packages/rstack/src/fmt/stdin.ts b/packages/rstack/src/fmt/stdin.ts index ec69200d..f6b6a5f6 100644 --- a/packages/rstack/src/fmt/stdin.ts +++ b/packages/rstack/src/fmt/stdin.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; +import { createFmtOptionsResolver } from './config.ts'; import { createFileRequest } from './discovery.ts'; import { formatFmtSource } from './format.ts'; import { createIgnoreMatcher } from './ignore.ts'; @@ -78,7 +79,7 @@ const runFmtStdin = async ({ return; } - let file = createFileRequest(absolutePath, config); + let file = createFileRequest(absolutePath, createFmtOptionsResolver(config)); if (file.options.plugins?.length) { const { createFmtPluginResolver } = await import( /* rspackChunkName: 'fmtPlugins' */ diff --git a/packages/rstack/tests/fmt/config.test.ts b/packages/rstack/tests/fmt/config.test.ts index 605b8c55..e4d083b1 100644 --- a/packages/rstack/tests/fmt/config.test.ts +++ b/packages/rstack/tests/fmt/config.test.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { expect, test } from 'rstack/test'; -import { normalizeFmtConfig, resolveFmtOptions } from '../../src/fmt/config.ts'; +import { createFmtOptionsResolver, normalizeFmtConfig } from '../../src/fmt/config.ts'; const rootPath = path.join(import.meta.dirname, 'project'); @@ -12,8 +12,9 @@ test('reuses base options when no override matches', () => { }, rootPath, ); + const resolveOptions = createFmtOptionsResolver(config); - expect(resolveFmtOptions(path.join(rootPath, 'index.js'), config)).toBe(config.baseOptions); + expect(resolveOptions(path.join(rootPath, 'index.js'))).toBe(config.baseOptions); }); test('applies basename and path overrides in declaration order', () => { @@ -38,12 +39,25 @@ test('applies basename and path overrides in declaration order', () => { }, rootPath, ); + const resolveOptions = createFmtOptionsResolver(config); - const options = resolveFmtOptions(path.join(rootPath, 'src/index.ts'), config); - const testOptions = resolveFmtOptions(path.join(rootPath, 'src/index.test.ts'), config); + const options = resolveOptions(path.join(rootPath, 'src/index.ts')); + const testOptions = resolveOptions(path.join(rootPath, 'src/index.test.ts')); expect(options).not.toBe(config.baseOptions); expect(options).toEqual({ semi: true, singleQuote: true, tabWidth: 4 }); expect(testOptions).toEqual({ singleQuote: true }); expect(config.baseOptions).toEqual({ singleQuote: false }); }); + +test('applies overrides outside the config root', () => { + const config = normalizeFmtConfig( + { + overrides: [{ files: '../shared/*.ts', options: { semi: false } }], + }, + rootPath, + ); + const resolveOptions = createFmtOptionsResolver(config); + + expect(resolveOptions(path.join(rootPath, '../shared/index.ts'))).toEqual({ semi: false }); +}); From a1166664ab220ddc1f420b4072385d3f84a390f5 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 11:03:24 +0800 Subject: [PATCH 20/22] perf(fmt): optimize CLI display paths (#211) --- packages/rstack/src/fmt/cli.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index db0e4c94..bd78bdc5 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -5,6 +5,7 @@ import { parseArgs } from '../cli/args.ts'; import { loadRstackConfig } from '../config.ts'; import { resolveFmtConfig } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; +import { createRelativePathResolver } from './relativePath.ts'; import { runFmtFiles } from './runner.ts'; import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts'; @@ -115,9 +116,12 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { }; }; -const getDisplayPath = (cwd: string, filePath: string): string => { - const relativePath = path.relative(cwd, filePath); - return path.sep === '\\' ? relativePath.replaceAll('\\', '/') : relativePath; +const createDisplayPathResolver = (cwd: string): ((filePath: string) => string) => { + const resolveRelativePath = createRelativePathResolver(cwd); + + return path.sep === '\\' + ? (filePath) => resolveRelativePath(filePath).replaceAll('\\', '/') + : resolveRelativePath; }; const prettyTime = (seconds: number): string => { @@ -168,6 +172,7 @@ const logFmtResult = ( ): void => { let writtenCount = 0; let differentCount = 0; + const resolveDisplayPath = createDisplayPathResolver(cwd); for (const file of result.files) { if (file.status === 'written') { @@ -175,7 +180,7 @@ const logFmtResult = ( continue; } - const displayPath = getDisplayPath(cwd, file.path); + const displayPath = resolveDisplayPath(file.path); if (file.status === 'different') { differentCount++; logger[mode === 'check' ? 'error' : 'log'](displayPath); From 133c05705c634a7dbdf01d144cd497e8475a0b06 Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 11:13:53 +0800 Subject: [PATCH 21/22] refactor(fmt): centralize path helpers (#212) --- packages/rstack/src/fmt/cli.ts | 7 ++----- packages/rstack/src/fmt/config.ts | 2 +- packages/rstack/src/fmt/discoverPaths.ts | 9 +++++---- packages/rstack/src/fmt/ignore.ts | 2 +- .../rstack/src/fmt/{relativePath.ts => pathHelpers.ts} | 5 ++++- .../fmt/{relativePath.test.ts => pathHelpers.test.ts} | 6 +++++- 6 files changed, 18 insertions(+), 13 deletions(-) rename packages/rstack/src/fmt/{relativePath.ts => pathHelpers.ts} (71%) rename packages/rstack/tests/fmt/{relativePath.test.ts => pathHelpers.test.ts} (76%) diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index bd78bdc5..a01416df 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -1,11 +1,10 @@ -import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { color, logger } from 'rslog'; import { parseArgs } from '../cli/args.ts'; import { loadRstackConfig } from '../config.ts'; import { resolveFmtConfig } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; -import { createRelativePathResolver } from './relativePath.ts'; +import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; import { runFmtFiles } from './runner.ts'; import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts'; @@ -119,9 +118,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const createDisplayPathResolver = (cwd: string): ((filePath: string) => string) => { const resolveRelativePath = createRelativePathResolver(cwd); - return path.sep === '\\' - ? (filePath) => resolveRelativePath(filePath).replaceAll('\\', '/') - : resolveRelativePath; + return (filePath) => toPosixPath(resolveRelativePath(filePath)); }; const prettyTime = (seconds: number): string => { diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index 86ba749f..67b9609b 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -1,6 +1,6 @@ import { dirname } from 'node:path'; import micromatch from 'micromatch'; -import { createRelativePathResolver } from './relativePath.ts'; +import { createRelativePathResolver } from './pathHelpers.ts'; import type { FmtConfig, FmtConfigDefinition, diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index c8f25d9a..d236bc6b 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -4,7 +4,11 @@ import ignore from 'ignore'; import isBinaryPath from 'is-binary-path'; import micromatch from 'micromatch'; import readdir, { type Dirent, type DirentLike } from 'tiny-readdir'; -import { createRelativePathResolver, type RelativePathResolver } from './relativePath.ts'; +import { + createRelativePathResolver, + toPosixPath, + type RelativePathResolver, +} from './pathHelpers.ts'; const defaultIgnoredDirNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); @@ -39,9 +43,6 @@ const isRelativePathInside = (relativePath: string): boolean => 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; diff --git a/packages/rstack/src/fmt/ignore.ts b/packages/rstack/src/fmt/ignore.ts index 33daf474..b449318f 100644 --- a/packages/rstack/src/fmt/ignore.ts +++ b/packages/rstack/src/fmt/ignore.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import createIgnore from 'ignore'; -import { createRelativePathResolver } from './relativePath.ts'; +import { createRelativePathResolver } from './pathHelpers.ts'; import type { ResolvedFmtConfig } from './types.ts'; /** diff --git a/packages/rstack/src/fmt/relativePath.ts b/packages/rstack/src/fmt/pathHelpers.ts similarity index 71% rename from packages/rstack/src/fmt/relativePath.ts rename to packages/rstack/src/fmt/pathHelpers.ts index 9e5b1d09..b5d90aaf 100644 --- a/packages/rstack/src/fmt/relativePath.ts +++ b/packages/rstack/src/fmt/pathHelpers.ts @@ -2,6 +2,9 @@ import path from 'node:path'; type RelativePathResolver = (filePath: string) => string; +const toPosixPath: (filePath: string) => string = + path.sep === '\\' ? (filePath) => filePath.replaceAll('\\', '/') : (filePath) => filePath; + const createRelativePathResolver = (rootPath: string): RelativePathResolver => { const rootPrefix = rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; @@ -13,5 +16,5 @@ const createRelativePathResolver = (rootPath: string): RelativePathResolver => { : path.relative(rootPath, filePath); }; -export { createRelativePathResolver }; +export { createRelativePathResolver, toPosixPath }; export type { RelativePathResolver }; diff --git a/packages/rstack/tests/fmt/relativePath.test.ts b/packages/rstack/tests/fmt/pathHelpers.test.ts similarity index 76% rename from packages/rstack/tests/fmt/relativePath.test.ts rename to packages/rstack/tests/fmt/pathHelpers.test.ts index 5b07d90a..e1b1a2c6 100644 --- a/packages/rstack/tests/fmt/relativePath.test.ts +++ b/packages/rstack/tests/fmt/pathHelpers.test.ts @@ -1,9 +1,13 @@ import path from 'node:path'; import { expect, test } from 'rstack/test'; -import { createRelativePathResolver } from '../../src/fmt/relativePath.ts'; +import { createRelativePathResolver, toPosixPath } from '../../src/fmt/pathHelpers.ts'; const rootPath = path.join(import.meta.dirname, 'project'); +test('converts platform paths to POSIX paths', () => { + expect(toPosixPath(path.join('src', 'index.ts'))).toBe('src/index.ts'); +}); + test('resolves paths relative to a fixed root', () => { const resolveRelativePath = createRelativePathResolver(rootPath); From 43905a627de89fea5eebfdd8fa32f0b1bedcf9fe Mon Sep 17 00:00:00 2001 From: Jiahan Chen Date: Thu, 6 Aug 2026 11:37:48 +0800 Subject: [PATCH 22/22] release: v0.3.4 (#214) --- packages/rstack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rstack/package.json b/packages/rstack/package.json index d11273c0..5638bdc0 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -1,6 +1,6 @@ { "name": "rstack", - "version": "0.3.3", + "version": "0.3.4", "description": "One CLI for JavaScript development, powered by Rstack.", "homepage": "https://rstack.rs", "bugs": {