Files
nodeagent/scripts/lib/version-write.mjs
T
jkooe 6adf11c85a fix(test): 两条守卫在 Windows 上必红(CRLF 与路径分隔符)
CI run 38380306200(提交 73ed06f)Windows 单元测试 319/321,macOS 全绿。

① 仓库内全部版本号互一致 → 报 undefined apps/console/src-tauri/Cargo.lock
   根因:actions/checkout 在 Windows 默认 core.autocrlf=true → CRLF,
   而 Cargo.lock 的版本提取把 \n 写死。**同一隐患也在生产代码里**:
   version-write.mjs 另有一条 name…\nversion… 正则读旧值,CRLF 下返回
   undefined → 把 Cargo.lock 记入 skipped 并跳过 → 发版后滞留旧版本
   (正是本模块要消灭的分叉,且不报错)。
   修法:读旧值收敛到 readCargoTomlVersion / setCargoLockVersion().before
   单一真源,删掉重复正则;新增 splitLineEol() 拆「正文+行尾」后改写
   (不能写 ("".*)$ 兜行尾 —— JS 的 . 不匹配 \r)。

② W-01 根因守卫 → 把唯一真源自己判成违规(util\quote.ts)
   根因:file.slice(dir.length+1) 在 Windows 得到反斜杠路径,与白名单
   的 util/quote.ts 比不中 → 守卫自己错。
   修法:新增 relPosix() 归一成 /。

新增 4 条守卫:Cargo.lock / Cargo.toml 的 CRLF 纯函数用例、
CRLF 工作区上真跑 writeVersionEverywhere 再读回文件、
以及用 path.win32 形态字符串在 macOS 上直接测 Windows 分支。
注入故障实测 4 条如期变红;test:unit 325/325(321 + 4)。
2026-10-13 18:57:04 +08:00

239 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 版本号写入 —— 发版脚本与测试**共用**的唯一实现。
*
* 为什么单独成模块(2026-10-13):
* `scripts/release.mjs` 原先只覆盖 `package.json` + `tauri.conf.json`,
* 但 `apps/console/src-tauri/Cargo.toml` 与 `Cargo.lock` 里**同样存着版本号**。
* 发版后这两处会**滞留旧版本**,与 `tauri.conf.json` 悄悄分叉 ——
* 而分叉不报错(Tauri 打包以 `tauri.conf.json` 为准),属于最难发现的那类漂移。
*
* 为什么把它从脚本里抽出来:
* 铁律「守卫要落在**最终产物字节**上」。若版本写入逻辑埋在 `release.mjs` 里,
* 单测只能断言「源码里出现过某个字符串」—— 那种守卫注入故障都不会变红。
* 抽成纯函数后,测试可以对**临时目录里的真实文件**跑一遍再断言文件内容。
*
* ⚠️ 换行符纪律(2026-10-13 Windows CI 实证后补):
* Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,检出的文本是 **CRLF**。
* 因此本模块内**一律不得**对原文跑把 `\n` 写死的正则 —— 那种写法在 CRLF 检出上会
* **静默返回 `undefined`**,而调用方普遍把它当成「没这个字段」直接跳过:不报错、
* 只是悄悄没改(正是本模块要消灭的那类漂移)。
* 统一做法:
* ① **读**值一律 `split('\n')` 后对 `line.trim()` 匹配(`\r` 被 `trim()` 吃掉);
* ② **写**值先用 `splitLineEol()` 把行尾拆下来、改完正文再拼回去。
* (不能偷懒写 `(".*)$` 兜行尾 —— JS 的 `.` **不匹配 `\r`**,那样照样失配。)
* 两条合起来保证:LF / CRLF 两种检出**结果一致**,且写回**逐字节保真**。
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/** 拿 `git ls-files` 的结果(只认已入库文件 —— 未入库的构建产物不该被改版本号)。 */
function lsFiles(root, pattern) {
try {
return execFileSync('git', ['ls-files', pattern], { cwd: root, encoding: 'utf8' })
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
} catch {
return [];
}
}
/**
* 枚举「存着版本号、需要一起改」的文件。
* Cargo 相关文件用 `optional: true` 标记:没有 Rust 侧时不该让发版直接失败。
*/
export function versionTargets(root = process.cwd()) {
const pkgFiles = ['package.json'];
for (const f of lsFiles(root, '*package.json')) {
if (f === 'package.json' || f.includes('node_modules')) continue;
pkgFiles.push(f);
}
return {
pkgFiles,
tauriConfFiles: lsFiles(root, '*src-tauri/tauri.conf.json'),
cargoTomlFiles: lsFiles(root, '*src-tauri/Cargo.toml'),
cargoLockFiles: lsFiles(root, '*src-tauri/Cargo.lock'),
};
}
/**
* 把一行拆成「正文 + 行尾」—— 行尾可能是 `\r`(CRLF 检出)或空串。
*
* ⚠️ 为什么必须拆:JS 里 `.` **不匹配 `\r`**(`\r` 与 `\n` 同属行终止符),
* 所以 `^(\s*version\s*=\s*")[^"]*(".*)$` 这类写法在 `version = "2.0.3"\r`
* 上会**直接失配** —— 行没改成、`done` 也没置位,全程不报错。
* 2026-10-13 本地复现(CRLF 文本喂给纯函数)即命中此处。
*/
function splitLineEol(line) {
return line.endsWith('\r') ? [line.slice(0, -1), '\r'] : [line, ''];
}
/**
* 改 `Cargo.toml` 的版本号 —— **只在 `[package]` 段内**改。
*
* 为什么要限定段落:`Cargo.toml` 里 `version = "..."` 这个形状还出现在别处
* (例如 `[dependencies]` 里 `some-crate = { version = "1" }` 是行内的,
* 但某些写法会把 `version = "..."` 单独成行)。不做限定就成了「全文盲替换」,
* 会改坏依赖约束 —— 那比不改更糟。
*/
export function setCargoTomlVersion(text, version, label = 'Cargo.toml') {
const lines = text.split('\n');
let inPackage = false;
let done = false;
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('[')) {
inPackage = trimmed === '[package]';
continue;
}
if (!inPackage || done) continue;
if (/^version\s*=/.test(trimmed)) {
const [body, eol] = splitLineEol(lines[i]);
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
if (m) {
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
done = true;
}
}
}
if (!done) throw new Error(`${label} 的 [package] 段里找不到 version 行`);
return lines.join('\n');
}
/** 从 `Cargo.toml` 的 `[package]` 段读版本号(读不到返回 `undefined`)。 */
export function readCargoTomlVersion(text) {
return packageSectionValue(text, 'version');
}
/**
* 在 `[package]` 段内取某个 `key = "值"` 的值。
*
* ⚠️ 一律走 `line.trim()` 再匹配(而不是对原文跑带 `$` / `\n` 的正则)——
* **这样换行符就与结果无关**:CRLF 的 `\r` 被 `trim()` 吃掉,
* LF / CRLF 两种检出得到同一结果。2026-10-13 Windows CI 实证:
* 写死 `\n` 的提取在 CRLF 检出上**静默返回 undefined**,
* 而调用方把它当成「没有这个字段」直接跳过 —— 不报错、只是悄悄没改。
*/
function packageSectionValue(text, key) {
const lines = text.split('\n');
let inPackage = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('[')) {
inPackage = trimmed === '[package]';
continue;
}
if (!inPackage) continue;
const m = new RegExp(`^${key}\\s*=\\s*"([^"]*)"$`).exec(trimmed);
if (m) return m[1];
}
return undefined;
}
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
export function readCargoPackageName(text) {
return packageSectionValue(text, 'name') ?? null;
}
/**
* 改 `Cargo.lock` 里**指定 crate** 的版本号。
*
* `Cargo.lock` 是「按 `[[package]]` 分块、块内 `name` 后跟 `version`」的结构,
* 且全文散布着**大量同名依赖版本**(`thiserror 2.0.21`、`proc-macro-crate 2.0.2` …)。
* 直接 `replace('2.0.2', ...)` 会把依赖版本一起改坏 —— 所以必须按块定位。
* 找不到该包时返回原文本(未构建过的 checkout 里可能没有),不报错。
*/
export function setCargoLockVersion(text, pkgName, version) {
const lines = text.split('\n');
let currentName = null;
let done = false;
let before;
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim();
if (trimmed === '[[package]]') {
currentName = null;
continue;
}
const nameMatch = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
if (nameMatch) {
currentName = nameMatch[1];
continue;
}
if (currentName === pkgName && !done && /^version\s*=/.test(trimmed)) {
// 先拆掉行尾再改正文,改完把原行尾接回去 → CRLF 检出下换行符**逐字节保真**,
// 不会把 Windows 工作区悄悄转成 LF(那会污染整个文件的 diff)。
const [body, eol] = splitLineEol(lines[i]);
const m = /^(\s*version\s*=\s*")([^"]*)(".*)$/.exec(body);
if (m) {
before = m[2];
lines[i] = `${m[1]}${version}${m[3]}${eol}`;
done = true;
}
}
}
return { text: lines.join('\n'), changed: done, before };
}
/**
* 把版本号写到**全部**存放它的地方。
*
* @returns {{ changed: Array<{file:string, before:string, kind:string}>, skipped: string[] }}
*/
export function writeVersionEverywhere(root, version, { dryRun = false } = {}) {
const targets = versionTargets(root);
const changed = [];
const skipped = [];
// ---- JSON 类:package.json 与 tauri.conf.json 同构,写回时保持 2 空格缩进 ----
for (const rel of [...targets.pkgFiles, ...targets.tauriConfFiles]) {
const abs = join(root, rel);
let parsed;
try {
parsed = JSON.parse(readFileSync(abs, 'utf8'));
} catch {
skipped.push(`${rel}(读不出 JSON)`);
continue;
}
if (!parsed.version) {
skipped.push(`${rel}(无 version 字段)`);
continue;
}
changed.push({ file: rel, before: parsed.version, kind: 'json' });
if (dryRun) continue;
parsed.version = version;
writeFileSync(abs, `${JSON.stringify(parsed, null, 2)}\n`);
}
// ---- Cargo.toml + Cargo.lock:成对处理(lock 里要按 crate 名定位) ----
//
// ⚠️ 「读旧值」与「写新值」**必须共用同一份实现**(`readCargoTomlVersion` /
// `setCargoLockVersion`)。早先这里另写了一条 `name…\nversion…` 的正则读旧值,
// 那与写入实现是**两份会各自漂移的解析**:2026-10-13 Windows CI 实证,
// CRLF 检出上那条正则返回 `undefined` → 本函数把它当成「没找到该 crate」记入
// `skipped` 并**跳过 Cargo.lock**,发版后该文件滞留旧版本 ——
// 正是本模块要消灭的那类分叉,而且**不报错**。
for (const rel of targets.cargoTomlFiles) {
const abs = join(root, rel);
const text = readFileSync(abs, 'utf8');
const crateName = readCargoPackageName(text);
changed.push({ file: rel, before: readCargoTomlVersion(text) ?? '?', kind: 'cargo-toml' });
if (!dryRun) writeFileSync(abs, setCargoTomlVersion(text, version, rel));
if (!crateName) continue;
const lockRel = targets.cargoLockFiles.find((f) => f.startsWith(rel.slice(0, rel.length - 'Cargo.toml'.length)));
if (!lockRel) continue;
const lockAbs = join(root, lockRel);
const lockRes = setCargoLockVersion(readFileSync(lockAbs, 'utf8'), crateName, version);
if (lockRes.before === undefined) {
skipped.push(`${lockRel}(未找到 crate ${crateName},可能尚未构建)`);
continue;
}
changed.push({ file: lockRel, before: lockRes.before, kind: 'cargo-lock' });
if (!dryRun && lockRes.changed) writeFileSync(lockAbs, lockRes.text);
}
return { changed, skipped };
}