From 3b385ef5757b9588e80cad555c8dfcc631cc3630 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 19:44:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PR316=20review=EF=BC=9Apre?= =?UTF-8?q?-commit=20Rust=20=E6=A0=BC=E5=BC=8F=E6=A3=80=E6=9F=A5=E6=8C=89?= =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=96=87=E4=BB=B6=E7=AD=9B=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/lint-staged-rustfmt.mjs:12 [maintainability medium] 改为读取 lint-staged 追加的暂存 .rs 路径,只对真正命中暂存文件的 workspace 跑 cargo fmt --all -- --check:此前永远全量跑两个 workspace,只暂存 server-rs 干净文件的提交会被别人未暂存的 src-tauri 改动挡下来;没收到暂存列表(手工执行)时回退全量检查并在 stderr 说明,避免假装检查过。 - scripts/lint-staged-rustfmt.mjs:7 [maintainability low] apps/desktop-shell/src-tauri 显式记为「有意排除」而不是静默通过:实测该 workspace 当前整体不过 cargo fmt --check(host_bridge/mod.rs 等),直接纳入会让任何 Rust 提交都失败;命中该前缀的暂存文件会打印跳过提示,纳入步骤写在模块注释里(同时要补 package.json 的 check:rustfmt / format:rust,package.json 不在本批次改动范围)。 - 新增 scripts/lint-staged-rustfmt-workspaces.mjs:把「暂存路径 → 待检查 workspace」的纯映射逻辑独立出来,附 Windows 反斜杠/绝对路径归一化。 - 新增 scripts/lint-staged-rustfmt.test.ts(vitest,随 npm test 运行):覆盖只暂存单侧 workspace 的回归判据、手工执行回退全量、排除项与未登记路径必须被点名、路径归一化,以及与 package.json check:rustfmt 的 workspace 表同集交叉校验。 --- scripts/lint-staged-rustfmt-workspaces.mjs | 102 +++++++++++++++++++ scripts/lint-staged-rustfmt.mjs | 42 ++++++-- scripts/lint-staged-rustfmt.test.ts | 113 +++++++++++++++++++++ 3 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 scripts/lint-staged-rustfmt-workspaces.mjs create mode 100644 scripts/lint-staged-rustfmt.test.ts diff --git a/scripts/lint-staged-rustfmt-workspaces.mjs b/scripts/lint-staged-rustfmt-workspaces.mjs new file mode 100644 index 000000000..c3118cdc1 --- /dev/null +++ b/scripts/lint-staged-rustfmt-workspaces.mjs @@ -0,0 +1,102 @@ +/** + * pre-commit 的 Rust 格式检查只按 Cargo workspace 粒度执行:`cargo fmt` 不接受「只格式化 + * 某个文件」(按文件跑 rustfmt 又会丢掉 manifest 的 edition 口径),所以 hook 收到的是 + * lint-staged 追加的暂存 `.rs` 路径,而真正执行的是 `cargo fmt --all --manifest-path ...`。 + * + * 这段映射逻辑单独成模块,是为了能脱离 cargo 单测:`--all` 会连**未暂存**的在改文件一起 + * 检查,如果无脑跑完所有 workspace,一个只暂存了 server-rs 干净文件的提交会被「别人正在改的 + * src-tauri 文件没格式化」挡下来。按暂存路径筛选 workspace 才是「只查暂存的」的正确粒度。 + */ + +/** 受检查的 workspace:`prefix` 是仓库相对路径前缀,`manifestPath` 是它的 Cargo manifest。 */ +export const RUSTFMT_WORKSPACES = [ + { prefix: 'server-rs/', manifestPath: 'server-rs/Cargo.toml' }, + { + prefix: 'apps/ai-game-creator-shell/src-tauri/', + manifestPath: 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + }, +]; + +/** + * 有意排除的第三个 workspace:`apps/desktop-shell/src-tauri`。 + * + * `package.json` 的 `*.rs` glob 会命中它的文件,但仓库当前**整体**不过 + * `cargo fmt --check`(2026-09 实测 `apps/desktop-shell/src-tauri/src/host_bridge/mod.rs` + * 等仍待格式化)。直接纳入会让任何一次只改了别处的 Rust 提交因为历史未格式化文件而失败, + * 所以这里显式记录「不查」,而不是让它静默通过:CLI 会对命中该前缀的暂存文件打印提示。 + * 纳入前先单独跑一次 + * `cargo fmt --all --manifest-path apps/desktop-shell/src-tauri/Cargo.toml` 并把它加进 + * `RUSTFMT_WORKSPACES`(同时补 `package.json` 的 `check:rustfmt` / `format:rust`)。 + */ +export const RUSTFMT_EXCLUDED_WORKSPACES = [ + { prefix: 'apps/desktop-shell/src-tauri/' }, +]; + +/** 把 lint-staged 传来的路径统一成「仓库相对 + 正斜杠」形式,便于前缀比较。 */ +function normalizeStagedPath(filePath, repoRoot) { + if (typeof filePath !== 'string') return null; + const normalized = filePath.replaceAll('\\', '/').replace(/^\.\//u, ''); + if (normalized.length === 0) return null; + + const normalizedRoot = repoRoot?.replaceAll('\\', '/').replace(/\/+$/u, ''); + if (!normalizedRoot) return normalized; + + const isAbsolute = + /^[A-Za-z]:\//u.test(normalized) || normalized.startsWith('/'); + if (!isAbsolute) return normalized; + + const windowsRoot = normalizedRoot.startsWith('/') + ? normalizedRoot + : `/${normalizedRoot}`; + const absolutePrefix = `${normalizedRoot}/`; + const absolutePrefixWindows = `${windowsRoot}/`; + const lowerPath = normalized.toLowerCase(); + for (const prefix of [absolutePrefix, absolutePrefixWindows]) { + if (lowerPath.startsWith(prefix.toLowerCase())) { + return normalized.slice(prefix.length); + } + } + return normalized; +} + +/** + * 由暂存 `.rs` 路径筛出要跑 `cargo fmt --check` 的 workspace。 + * + * - 没有暂存文件列表(手工执行)时保持旧的「全量检查」语义; + * - 只检查真的被暂存文件命中的 workspace; + * - 命中排除前缀 / 任何已知 workspace 之外的暂存文件单独回报,由调用方提示, + * 避免「glob 命中了但谁都没查」这种静默通过。 + */ +export function selectRustfmtWorkspaces({ stagedFiles, repoRoot }) { + const normalized = (stagedFiles ?? []) + .map((filePath) => normalizeStagedPath(filePath, repoRoot)) + .filter((filePath) => filePath !== null); + + if (normalized.length === 0) { + return { + workspaces: RUSTFMT_WORKSPACES, + excluded: [], + unmanaged: [], + usedStagedList: false, + }; + } + + const hitsPrefix = (filePath, prefix) => filePath.startsWith(prefix); + const hitsAnyPrefix = (filePath, entries) => + entries.some(({ prefix }) => hitsPrefix(filePath, prefix)); + + return { + workspaces: RUSTFMT_WORKSPACES.filter((workspace) => + normalized.some((filePath) => hitsPrefix(filePath, workspace.prefix)), + ), + excluded: normalized.filter((filePath) => + hitsAnyPrefix(filePath, RUSTFMT_EXCLUDED_WORKSPACES), + ), + unmanaged: normalized.filter( + (filePath) => + !hitsAnyPrefix(filePath, RUSTFMT_WORKSPACES) && + !hitsAnyPrefix(filePath, RUSTFMT_EXCLUDED_WORKSPACES), + ), + usedStagedList: true, + }; +} diff --git a/scripts/lint-staged-rustfmt.mjs b/scripts/lint-staged-rustfmt.mjs index 09aea7574..51b35afcc 100644 --- a/scripts/lint-staged-rustfmt.mjs +++ b/scripts/lint-staged-rustfmt.mjs @@ -1,15 +1,43 @@ import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { selectRustfmtWorkspaces } from './lint-staged-rustfmt-workspaces.mjs'; // lint-staged 会把命中的暂存文件路径追加到命令末尾,而 `cargo fmt` 只按 workspace 粒度格式化、 -// 不接受文件参数(也做不到「只格式化某个文件」),所以这里忽略 argv,直接对两个 workspace 跑 -// `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。 -const workspaces = [ - 'server-rs/Cargo.toml', - 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', -]; +// 不接受文件参数(也做不到「只格式化某个文件」),所以这里用暂存路径筛出**需要检查的 +// workspace**,再对它们跑 `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。 +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const { workspaces, excluded, unmanaged, usedStagedList } = + selectRustfmtWorkspaces({ + stagedFiles: process.argv.slice(2), + repoRoot, + }); -for (const manifestPath of workspaces) { +// 静默通过是这里最贵的失败模式:glob 命中了文件、但没有任何 workspace 检查到它。 +if (excluded.length > 0) { + process.stderr.write( + `Rust 格式检查跳过(有意排除的 workspace):${excluded.join(', ')}\n` + + `该 workspace 当前整体未过 \`cargo fmt --check\`,详见 scripts/lint-staged-rustfmt-workspaces.mjs。\n`, + ); +} +if (unmanaged.length > 0) { + process.stderr.write( + `Rust 格式检查未覆盖以下暂存文件(不在任何已登记 workspace 内):${unmanaged.join(', ')}\n` + + `如果是新 workspace,请登记到 scripts/lint-staged-rustfmt-workspaces.mjs。\n`, + ); +} + +// 一条都没筛出来又不曾拿到暂存列表,说明调用方式变了(例如 lint-staged 不再追加参数): +// 宁可回退到全量检查,也不要假装检查过了。 +if (!usedStagedList) { + process.stderr.write( + 'Rust 格式检查未收到 lint-staged 的暂存文件列表,回退为全量检查所有 workspace。\n', + ); +} + +for (const { manifestPath } of workspaces) { const result = spawnSync( 'cargo', ['fmt', '--all', '--manifest-path', manifestPath, '--', '--check'], diff --git a/scripts/lint-staged-rustfmt.test.ts b/scripts/lint-staged-rustfmt.test.ts new file mode 100644 index 000000000..fdaff2869 --- /dev/null +++ b/scripts/lint-staged-rustfmt.test.ts @@ -0,0 +1,113 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from 'vitest'; + +import { + RUSTFMT_EXCLUDED_WORKSPACES, + RUSTFMT_WORKSPACES, + selectRustfmtWorkspaces, +} from './lint-staged-rustfmt-workspaces.mjs'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function selectedManifests(stagedFiles: string[], root = repoRoot) { + return selectRustfmtWorkspaces({ + stagedFiles, + repoRoot: root, + }).workspaces.map((workspace) => workspace.manifestPath); +} + +describe('lint-staged Rust 格式检查的 workspace 选择', () => { + test('只暂存 server-rs 时不再连带检查 src-tauri', () => { + // 回归判据:`cargo fmt --all` 会连未暂存的在改文件一起查,全量跑两个 workspace + // 会让「只暂存了 server-rs 干净文件」的提交被别人的 src-tauri 改动挡下来。 + expect( + selectedManifests([ + 'server-rs/crates/api-server/src/editor_project.rs', + 'server-rs/crates/shared-contracts/src/game_creation_app.rs', + ]), + ).toEqual(['server-rs/Cargo.toml']); + }); + + test('只暂存 src-tauri 时不再连带检查 server-rs', () => { + expect( + selectedManifests(['apps/ai-game-creator-shell/src-tauri/src/assets.rs']), + ).toEqual(['apps/ai-game-creator-shell/src-tauri/Cargo.toml']); + }); + + test('两个 workspace 都有暂存文件时两个都查', () => { + expect( + selectedManifests([ + 'server-rs/crates/api-server/src/editor_project.rs', + 'apps/ai-game-creator-shell/src-tauri/src/assets.rs', + ]), + ).toEqual([ + 'server-rs/Cargo.toml', + 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ]); + }); + + test('手工执行(没有暂存列表)时保持全量检查语义', () => { + const selection = selectRustfmtWorkspaces({ stagedFiles: [], repoRoot }); + expect(selection.usedStagedList).toBe(false); + expect(selection.workspaces).toEqual(RUSTFMT_WORKSPACES); + }); + + test('排除的 workspace 与未登记路径都会被显式报出,不静默通过', () => { + const selection = selectRustfmtWorkspaces({ + stagedFiles: [ + 'apps/desktop-shell/src-tauri/src/host_bridge/mod.rs', + 'somewhere-new/src/lib.rs', + ], + repoRoot, + }); + // desktop-shell 当前整体未过 `cargo fmt --check`,所以它不参与检查…… + expect(selection.workspaces).toEqual([]); + // ……但必须被点名,否则「glob 命中了、谁都没查」就是静默通过。 + expect(selection.excluded).toEqual([ + 'apps/desktop-shell/src-tauri/src/host_bridge/mod.rs', + ]); + expect(selection.unmanaged).toEqual(['somewhere-new/src/lib.rs']); + }); + + test('Windows 反斜杠与绝对路径都归一化成仓库相对路径', () => { + const root = 'C:\\repo\\genarrative'; + expect( + selectedManifests( + [ + 'server-rs\\crates\\api-server\\src\\editor_project.rs', + `${root}\\apps\\ai-game-creator-shell\\src-tauri\\src\\assets.rs`, + ], + root, + ), + ).toEqual([ + 'server-rs/Cargo.toml', + 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ]); + }); + + test('检查清单与 package.json 的 check:rustfmt 保持同一份 workspace 表', () => { + // 两处各写一份 workspace 列表就会漂移:hook 查的与 `npm run check:rustfmt` 查的必须同集。 + const packageJson = JSON.parse( + readFileSync(resolve(repoRoot, 'package.json'), 'utf8'), + ) as { scripts: Record }; + const manifests = Array.from( + packageJson.scripts['check:rustfmt']!.matchAll( + /--manifest-path\s+(\S+)\s+--\s+--check/gu, + ), + ).map((match) => match[1]!); + expect(manifests).toEqual( + RUSTFMT_WORKSPACES.map((workspace) => workspace.manifestPath), + ); + // 排除项必须是「检查清单之外」的,否则这段注释与实际行为不符。 + for (const excluded of RUSTFMT_EXCLUDED_WORKSPACES) { + expect( + RUSTFMT_WORKSPACES.some((workspace) => + workspace.prefix.startsWith(excluded.prefix), + ), + ).toBe(false); + } + }); +});