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)。
This commit is contained in:
jkooe committed 2026-10-13 18:57:04 +08:00
1 parent 73ed06f5f1
commit 6adf11c85a
4 files changed
+257 -29

No files matched your search

+38
View File
@@ -375,6 +375,44 @@ e2e 新增的 ACL / 畸形报文用例有个必须记住的姿势:**每个场
> (`.msi` / NSIS);直接重启已装的旧版控制台只是**临时绕开**(进程内缓存被清空 → 重新拉起 sidecar),
> 症状会消失,但下次 sidecar 再退出仍会复发。
### 修复 —— 两条守卫在 Windows 上必红(平台差异:CRLF 与路径分隔符)
CI 实证(run `38380306200`,提交 `73ed06f`):`Windows 真机验证 → 单元测试` **319/321**,
macOS 上同样两条却是绿的 —— 典型的「只在 Windows 暴露」。两条各有明确根因:
**① `仓库内全部版本号互一致`:CRLF 把版本号读成 `undefined`**
报错原文:`undefined apps/console/src-tauri/Cargo.lock`(其余 10 处均为 `2.0.3`,`2 !== 1`)。
根因:Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,工作区文本是 **CRLF**,
而 `Cargo.lock` 的版本提取把 `\n` 写死了。更严重的是 —— **同一处隐患也在生产代码里**:
`version-write.mjs` 的写入路径另有一条 `name…\nversion…` 正则读旧值,CRLF 下同样返回 `undefined`,
于是它把 Cargo.lock 当成「没找到该 crate」记入 `skipped` 并**跳过** → 发版后该文件滞留旧版本,
**正是本模块要消灭的那类分叉,且不报错**。
- 收敛为**单一真源**:读旧值改走 `readCargoTomlVersion()` / `setCargoLockVersion().before`,
删掉那条会漂移的重复正则。
- 新增 `splitLineEol()`:写值前先把行尾拆下来、改完正文再拼回去 ——
不能偷懒写 `(".*)$` 兜行尾,因为 **JS 的 `.` 不匹配 `\r`**(与 `\n` 同属行终止符),照样失配。
- 读值一律 `split('\n')` 后对 `line.trim()` 匹配 → LF / CRLF 结果一致,写回**逐字节保真**。
- 新增 3 条守卫:Cargo.lock / Cargo.toml 纯函数级 CRLF 用例 + **CRLF 工作区上真跑一遍
`writeVersionEverywhere` 并读回文件断言**(注入故障后 3 条如期变红)。
**② `W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.ts`:把唯一真源自己判成违规**
报错原文:`以下文件手写了 PS 单引号转义:util\quote.ts`。
根因:守卫用 `file.slice(dir.length + 1)` 得相对路径,Windows 上是 `util\quote.ts`,
与白名单里的 `util/quote.ts` 比不中 → **守卫自己错**(铁律 3 的第 ② 类),macOS 上复现不出。
- 新增 `relPosix()` 把相对路径统一归一成 `/`;并补一条**用 `path.win32` 形态字符串在 macOS 上
直接测掉 Windows 分支**的用例 —— 跨平台差异不必真上 Windows 才能验(铁律 4)。
回归:`test:unit` **325/325**(321 + 新增 4),注入故障实测 4 条守卫如期变红、还原逐字节一致。
> 附带发现(不构成缺陷,但值得记档):控制台随包的 `agent.mjs` 与发布版 `agent.mjs`
> **逐行一致、同报 2.0.3**,唯二差异是 esbuild 的模块相对路径串与内嵌 commit 常量
> (随包 `73ed06f` / 发布版 `a1c51d6`)—— 源于两次构建的**基准目录不同**。
> 因此二者 **sha256 必然不同**,不能用发布清单的哈希去校验控制台播种出来的那份。
---
## [v2.0.2] —— 2026-10-11
+74 -18
View File
@@ -11,6 +11,17 @@
* 铁律「守卫要落在**最终产物字节**上」。若版本写入逻辑埋在 `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';
@@ -47,6 +58,18 @@ export function versionTargets(root = process.cwd()) {
};
}
/**
* 把一行拆成「正文 + 行尾」—— 行尾可能是 `\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]` 段内**改。
*
@@ -67,16 +90,33 @@ export function setCargoTomlVersion(text, version, label = 'Cargo.toml') {
}
if (!inPackage || done) continue;
if (/^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
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]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
export function readCargoPackageName(text) {
/** 从 `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) {
@@ -86,10 +126,15 @@ export function readCargoPackageName(text) {
continue;
}
if (!inPackage) continue;
const m = /^name\s*=\s*"([^"]*)"$/.exec(trimmed);
const m = new RegExp(`^${key}\\s*=\\s*"([^"]*)"$`).exec(trimmed);
if (m) return m[1];
}
return null;
return undefined;
}
/** 从 `Cargo.toml` 的 `[package]` 段读 crate 名(用于去 `Cargo.lock` 里定位同一个包)。 */
export function readCargoPackageName(text) {
return packageSectionValue(text, 'name') ?? null;
}
/**
@@ -104,6 +149,7 @@ 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]]') {
@@ -116,11 +162,18 @@ export function setCargoLockVersion(text, pkgName, version) {
continue;
}
if (currentName === pkgName && !done && /^version\s*=/.test(trimmed)) {
lines[i] = lines[i].replace(/^(\s*version\s*=\s*")[^"]*(".*)$/, `$1${version}$2`);
done = true;
// 先拆掉行尾再改正文,改完把原行尾接回去 → 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 };
return { text: lines.join('\n'), changed: done, before };
}
/**
@@ -154,28 +207,31 @@ export function writeVersionEverywhere(root, version, { dryRun = false } = {}) {
}
// ---- 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 before = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(text)?.[1];
const crateName = readCargoPackageName(text);
changed.push({ file: rel, before: before ?? '?', kind: 'cargo-toml' });
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 lockText = readFileSync(lockAbs, 'utf8');
const lockBefore = new RegExp(`name\\s*=\\s*"${crateName}"\\nversion\\s*=\\s*"([^"]*)"`).exec(lockText)?.[1];
if (lockBefore === undefined) {
const lockRes = setCargoLockVersion(readFileSync(lockAbs, 'utf8'), crateName, version);
if (lockRes.before === undefined) {
skipped.push(`${lockRel}(未找到 crate ${crateName},可能尚未构建)`);
continue;
}
changed.push({ file: lockRel, before: lockBefore, kind: 'cargo-lock' });
if (dryRun) continue;
const result = setCargoLockVersion(lockText, crateName, version);
if (result.changed) writeFileSync(lockAbs, result.text);
changed.push({ file: lockRel, before: lockRes.before, kind: 'cargo-lock' });
if (!dryRun && lockRes.changed) writeFileSync(lockAbs, lockRes.text);
}
return { changed, skipped };
+32 -2
View File
@@ -108,6 +108,18 @@ function walk(dir) {
return out;
}
/**
* 相对路径一律归一成 `/` 分隔 —— 否则「白名单」在 Windows 上必然比不中。
*
* ⚠️ 2026-10-13 Windows CI 实证:`file.slice(dir.length + 1)` 在 Windows 上得到
* `util\quote.ts`,与白名单里的 `util/quote.ts` 不等 → **唯一真源自己被判成违规**
* (报错原文:`以下文件手写了 PS 单引号转义:util\quote.ts`)。
* 守卫自己错(铁律 3 的第 ② 类),而 macOS 上 100% 复现不出 —— 平台差异必须收敛掉。
*/
function relPosix(dir, file) {
return file.slice(dir.length + 1).replaceAll('\\', '/');
}
/**
* 去掉注释后再扫。
*
@@ -136,7 +148,7 @@ test('W-01 根因守卫:源文件里不得出现 `${JSON.stringify(…)}` 拼
];
const offenders = [];
for (const file of walk(AGENT_SRC)) {
const rel = file.slice(AGENT_SRC.length + 1);
const rel = relPosix(AGENT_SRC, file);
const lines = stripComments(readFileSync(file, 'utf8')).split('\n');
lines.forEach((line, i) => {
if (!line.includes('${JSON.stringify(')) return;
@@ -156,7 +168,7 @@ test('W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.t
const ALLOWED = new Set(['util/quote.ts', 'capabilities/network.ts']);
const offenders = [];
for (const file of walk(AGENT_SRC)) {
const rel = file.slice(AGENT_SRC.length + 1);
const rel = relPosix(AGENT_SRC, file);
if (ALLOWED.has(rel)) continue;
const text = stripComments(readFileSync(file, 'utf8'));
// 手写 `replace(/'/g, "''")` 就是「又发明了一份 psLit」——两份实现必然漂移
@@ -166,3 +178,21 @@ test('W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.t
}
assert.deepEqual(offenders, [], `以下文件手写了 PS 单引号转义,应改用 util/quote.ts:\n${offenders.join('\n')}`);
});
// ---------------------------------------------------------------------------
// 守卫自身的可信度:路径归一
//
// 前面两条守卫都靠「相对路径 = 白名单里的字面量」来判断。若这一步在 Windows 上
// 比不中,守卫就会**冤枉唯一真源自己**(CI 原文:`以下文件手写了 PS 单引号转义:util\quote.ts`)。
// 这里用 `path.win32` 形态的字符串在 macOS 上直接把 Windows 分支测掉 ——
// 跨平台差异不必真上 Windows 才能验(铁律 4)。
// ---------------------------------------------------------------------------
test('守卫自身的路径归一:Windows 反斜杠必须归一成 /(否则白名单比不中)', () => {
const winDir = 'C:\\x\\apps\\agent\\src';
assert.equal(relPosix(winDir, 'C:\\x\\apps\\agent\\src\\util\\quote.ts'), 'util/quote.ts');
assert.equal(relPosix(winDir, 'C:\\x\\apps\\agent\\src\\capabilities\\network.ts'), 'capabilities/network.ts');
const posixDir = '/x/apps/agent/src';
assert.equal(relPosix(posixDir, '/x/apps/agent/src/util/quote.ts'), 'util/quote.ts');
});
+113 -9
View File
@@ -21,6 +21,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
readCargoPackageName,
readCargoTomlVersion,
setCargoLockVersion,
setCargoTomlVersion,
versionTargets,
@@ -107,19 +108,77 @@ test('Cargo.lock:找不到该 crate 时返回原文并报 changed=false(未
assert.match(text, /name = "other"\nversion = "1\.0\.0"/);
});
// ---------------------------------------------------------------------------
// ②' 换行符无关性(CRLF)—— 2026-10-13 Windows CI 实证后补
//
// 为什么必须单列:Windows 上 `actions/checkout` 默认 `core.autocrlf=true`,
// 工作区文本是 CRLF。把 `\n` 写死进正则会在 CRLF 上**静默失配**,
// 而调用方普遍把 `undefined` 当成「没这个字段」→ **不报错、只是悄悄没改**。
// CI 报错原文:`undefined apps/console/src-tauri/Cargo.lock`(其余 10 处均为 2.0.3)。
// ---------------------------------------------------------------------------
test('Cargo.lock:CRLF 检出下同样读出旧值、改对目标 crate、换行符逐字节保真', () => {
const lf = 'version = 4\n\n[[package]]\nname = "nodeagent-console"\nversion = "2.0.3"\n\n[[package]]\nname = "statuses"\nversion = "2.0.2"\n';
const crlf = lf.replaceAll('\n', '\r\n');
// 读旧值:LF / CRLF 必须给出**同一个**结果(这正是 Windows 上失效的那一步)
assert.equal(setCargoLockVersion(lf, 'nodeagent-console', '9.9.9').before, '2.0.3');
assert.equal(
setCargoLockVersion(crlf, 'nodeagent-console', '9.9.9').before,
'2.0.3',
'CRLF 下读不出旧值 → 发版会静默跳过 Cargo.lock(Windows CI 实证失败点)',
);
const r = setCargoLockVersion(crlf, 'nodeagent-console', '9.9.9');
assert.equal(r.changed, true);
assert.match(r.text, /name = "nodeagent-console"\r\nversion = "9\.9\.9"\r\n/);
assert.match(r.text, /name = "statuses"\r\nversion = "2\.0\.2"/, '依赖版本不得被连带改掉');
assert.ok(!/[^\r]\n/.test(r.text), '换行符必须保持 CRLF —— 悄悄转成 LF 会污染整个文件的 diff');
});
test('Cargo.toml:CRLF 检出下 version 读写一致,且不碰非 [package] 段', () => {
const lf = [
'[package]',
'name = "nodeagent-console"',
'version = "2.0.3"',
'edition = "2021"',
'',
'[package.metadata.deb]',
'version = "2.0.3"',
'',
].join('\n');
const crlf = lf.replaceAll('\n', '\r\n');
assert.equal(readCargoTomlVersion(crlf), '2.0.3', 'CRLF 下必须读得出 [package] 的 version');
assert.equal(readCargoPackageName(crlf), 'nodeagent-console');
const out = setCargoTomlVersion(crlf, '9.9.9', 'Cargo.toml');
assert.match(out, /\[package\]\r\nname = "nodeagent-console"\r\nversion = "9\.9\.9"\r\n/);
assert.equal(out.match(/version = "2\.0\.3"/g)?.length, 1, '非 [package] 段那处必须原样保留');
assert.ok(!/[^\r]\n/.test(out), '换行符必须保持 CRLF');
});
// ---------------------------------------------------------------------------
// ③ 编排:对临时目录里的**真实文件**跑一遍
// ---------------------------------------------------------------------------
function scaffold() {
/**
* 铺一份「真实文件」的临时仓库。
*
* `crlf: true` 模拟 **Windows 检出**(`actions/checkout` 默认 `core.autocrlf=true`
* 会让工作区文本变成 CRLF)。2026-10-13 CI 实证:CRLF 下 Cargo.lock 的版本号
* 曾读出 `undefined`,生产代码据此**静默跳过**该文件 —— 必须能被本地复现(铁律 4)。
*/
function scaffold({ crlf = false } = {}) {
const eol = crlf ? '\r\n' : '\n';
const dir = mkdtempSync(join(tmpdir(), 'na-version-'));
const tauriDir = join(dir, 'apps', 'console', 'src-tauri');
mkdirSync(tauriDir, { recursive: true });
writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ name: 'nodeagent', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(join(tauriDir, 'tauri.conf.json'), `${JSON.stringify({ productName: 'x', version: '2.0.2' }, null, 2)}\n`);
writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ name: 'nodeagent', version: '2.0.2' }, null, 2)}${eol}`);
writeFileSync(join(tauriDir, 'tauri.conf.json'), `${JSON.stringify({ productName: 'x', version: '2.0.2' }, null, 2)}${eol}`);
writeFileSync(
join(tauriDir, 'Cargo.toml'),
['[package]', 'name = "nodeagent-console"', 'version = "2.0.2"', 'edition = "2021"', ''].join('\n'),
['[package]', 'name = "nodeagent-console"', 'version = "2.0.2"', 'edition = "2021"', ''].join(eol),
);
writeFileSync(
join(tauriDir, 'Cargo.lock'),
@@ -134,7 +193,7 @@ function scaffold() {
'name = "statuses"',
'version = "2.0.2"',
'',
].join('\n'),
].join(eol),
);
// 必须真的进一次 git 索引 —— `versionTargets` 走 `git ls-files`,
// 只认「已入库文件」,否则临时目录里它会返回空集,测试就会假绿。
@@ -177,6 +236,43 @@ test('dry-run 一个字节都不改', () => {
assert.match(readFileSync(join(dir, 'package.json'), 'utf8'), /"version": "2\.0\.2"/);
});
// ---------------------------------------------------------------------------
// ③' 编排层 + CRLF:这是**能打红生产缺陷**的那条守卫
//
// 前一条 CRLF 测试只证「纯函数对 CRLF 免疫」;但 Windows 上翻车的是**编排层**
// ——它曾另写一条正则读旧值,失配后把 Cargo.lock 记进 `skipped` 直接跳过。
// 所以必须在 CRLF 工作区上真跑一遍 `writeVersionEverywhere`,再读回文件断言。
// ---------------------------------------------------------------------------
test('CRLF 工作区(Windows 检出):四处版本号照样全改,且 skipped 必须为空', () => {
const dir = scaffold({ crlf: true });
const res = writeVersionEverywhere(dir, '9.9.9');
assert.deepEqual(
res.changed.map((c) => c.file).sort(),
[
'apps/console/src-tauri/Cargo.lock',
'apps/console/src-tauri/Cargo.toml',
'apps/console/src-tauri/tauri.conf.json',
'package.json',
].sort(),
);
assert.equal(
res.skipped.length,
0,
`CRLF 下不得有跳过项(Windows CI 上 Cargo.lock 曾被静默跳过):${res.skipped.join(', ')}`,
);
const lock = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.lock'), 'utf8');
assert.match(lock, /name = "nodeagent-console"\r\nversion = "9\.9\.9"/, 'Cargo.lock 必须真的被改到');
assert.match(lock, /name = "statuses"\r\nversion = "2\.0\.2"/, '依赖版本不得被连带改掉');
assert.ok(!/[^\r]\n/.test(lock), 'Cargo.lock 的换行符必须保持 CRLF');
const toml = readFileSync(join(dir, 'apps/console/src-tauri/Cargo.toml'), 'utf8');
assert.match(toml, /version = "9\.9\.9"/);
assert.ok(!/[^\r]\n/.test(toml), 'Cargo.toml 的换行符必须保持 CRLF');
});
// ---------------------------------------------------------------------------
// ④ 发版漂移守卫:真实仓库里 11 处版本号必须互一致
// ---------------------------------------------------------------------------
@@ -194,13 +290,21 @@ test('仓库内全部版本号互一致(发版漏项会在这里变红)', ()
for (const f of [...t.pkgFiles, ...t.tauriConfFiles]) {
versions.set(f, JSON.parse(readFileSync(join(repoRoot, f), 'utf8')).version);
}
// ⚠️ 读旧值一律走**生产函数**,不在这里另写一条正则。
// 2026-10-13 Windows CI 实证:本文件曾自己写 `name…\nversion…` 的正则,
// CRLF 检出上返回 undefined → 报「版本号分叉」;而**生产代码里有同一条正则**,
// 真实后果是发版时静默跳过 Cargo.lock(比测试变红严重得多)。
// 守卫与生产必须共用同一份解析,否则守卫只能验证「两份实现是否一致」。
for (const f of t.cargoTomlFiles) {
const m = /\[package\][\s\S]*?^version\s*=\s*"([^"]*)"/m.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
versions.set(f, readCargoTomlVersion(readFileSync(join(repoRoot, f), 'utf8')));
}
for (const f of t.cargoLockFiles) {
const m = /name\s*=\s*"nodeagent-console"\nversion\s*=\s*"([^"]*)"/.exec(readFileSync(join(repoRoot, f), 'utf8'));
versions.set(f, m?.[1]);
const { before } = setCargoLockVersion(
readFileSync(join(repoRoot, f), 'utf8'),
'nodeagent-console',
'__probe__',
);
versions.set(f, before);
}
const distinct = new Set(versions.values());