diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec6935..83a9b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/lib/version-write.mjs b/scripts/lib/version-write.mjs index b798765..87141f5 100644 --- a/scripts/lib/version-write.mjs +++ b/scripts/lib/version-write.mjs @@ -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 }; diff --git a/tests/unit/quote.test.mjs b/tests/unit/quote.test.mjs index db928b6..c47fea6 100644 --- a/tests/unit/quote.test.mjs +++ b/tests/unit/quote.test.mjs @@ -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'); +}); diff --git a/tests/unit/version-write.test.mjs b/tests/unit/version-write.test.mjs index 67dae59..d302339 100644 --- a/tests/unit/version-write.test.mjs +++ b/tests/unit/version-write.test.mjs @@ -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());