diff --git a/client/electron/test/web-comment-only.test.mjs b/client/electron/test/web-comment-only.test.mjs new file mode 100644 index 0000000..454d81d --- /dev/null +++ b/client/electron/test/web-comment-only.test.mjs @@ -0,0 +1,142 @@ +/** + * `deploy/web-comment-only.mjs` 的判据。 + * + * ★ 这条判据本身必须能区分「仅注释」与「真代码」—— + * 一条分不清的判据等于没有判据。所以下面每格都给**两侧**的对照: + * 注释 ⇒ 放行,代码 ⇒ 拦截。变异验证见文件末尾注释。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifySourceChanges, isCodeChange } from '../../../deploy/web-comment-only.mjs'; + +/** 假 run:按 map 返回预置 stdout。 */ +const fakeRun = (outputs) => (cmd) => { + for (const [key, value] of Object.entries(outputs)) { + if (cmd.join(' ').includes(key)) return { stdout: value, code: 0 }; + } + return { stdout: '', code: 0 }; +}; + +// ── 行级:什么算「真改动」 ────────────────────────────── + +test('★ 行级判据:注释行不算改动,代码行算', () => { + // 注释(这三类都放过) + assert.equal(isCodeChange('+ * 自线索根整树 BFS 取一块'), false, '块注释中间行'); + assert.equal(isCodeChange('- * dir=around 首屏'), false, '块注释行'); + assert.equal(isCodeChange('+ // 说明一下'), false, '行注释(缩进)'); + assert.equal(isCodeChange('+ */'), false, '块注释结尾'); + assert.equal(isCodeChange('+'), false, '纯增行空行'); + assert.equal(isCodeChange('-'), false, '纯删行空行'); + // 文件头与元信息 + assert.equal(isCodeChange('--- a/client/electron/src/api/client.ts'), false); + assert.equal(isCodeChange('+++ b/client/electron/src/api/client.ts'), false); + assert.equal(isCodeChange('@@ -1,3 +1,3 @@'), false); + + // 代码(这些必须拦住) + assert.equal(isCodeChange('+const x = 1;'), true); + assert.equal(isCodeChange('- const base = url;'), true); + assert.equal(isCodeChange('+ const base = url; // 带行尾注释也算真改动'), true, + '行尾注释不改变「这行有代码」的事实'); +}); + +// ── 文件级:只有注释 ⇒ 放行 ───────────────────────────── + +test('★ 只有注释差异 ⇒ 放行(这正是 2026-10-02 拦了两次部署的那个形状)', () => { + // 逐字取自当天实测的 git diff:client/electron/src/api/client.ts + const diff = [ + 'diff --git a/client/electron/src/api/client.ts b/client/electron/src/api/client.ts', + 'index 1111111..2222222 100644', + '--- a/client/electron/src/api/client.ts', + '+++ b/client/electron/src/api/client.ts', + '@@ -40,1 +40,1 @@', + '- * dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。', + '+ * 自线索根整树 BFS 取一块,offset 传上次返回的 next_offset 继续往后取。' + ].join('\n'); + + const r = classifySourceChanges( + ['client/electron/src/api/client.ts'], + '/repo', + fakeRun({ 'ls-files': '', 'diff': diff }) + ); + assert.equal(r.commentOnly, true, `应放行,实际 reason=${r.reason}`); + assert.match(r.reason, /仅为注释/); +}); + +// ── 文件级:有代码 ⇒ 拦截 ─────────────────────────────── + +test('★ 有非注释改动 ⇒ 拦截(否则旧界面会被嵌进二进制)', () => { + const diff = [ + '--- a/client/electron/src/api/client.ts', + '+++ b/client/electron/src/api/client.ts', + '@@ -10,1 +10,1 @@', + '+ * 顺手加了句说明', + '+const base = url.replace(/\\/+$/, "");' + ].join('\n'); + + const r = classifySourceChanges( + ['client/electron/src/api/client.ts'], + '/repo', + fakeRun({ 'ls-files': '', 'diff': diff }) + ); + assert.equal(r.commentOnly, false, `应拦截,实际 reason=${r.reason}`); + assert.match(r.reason, /非注释改动/); +}); + +// ★ 这一格是 2026-09-14 那个沉默失败的守门:改了请求路径却没重建 ⇒ +// 接口 404(/api/v1/api/v1/…)而单测全绿。 +test('★ 单行代码改动也必须拦截(只改一个字母那种)', () => { + const diff = [ + '--- a/client/electron/src/lib/appearance.ts', + '+++ b/client/electron/src/lib/appearance.ts', + '@@ -3,1 +3,1 @@', + '-const API = "/api/v1";', + '+const API = "/api/v1";' + ].join('\n'); + const r = classifySourceChanges( + ['client/electron/src/lib/appearance.ts'], + '/repo', + fakeRun({ 'ls-files': '', 'diff': diff }) + ); + assert.equal(r.commentOnly, false, '单行改动也必须重建'); +}); + +// ── 拿不准时一律拦 ───────────────────────────────────── + +test('★ 新增未跟踪文件 ⇒ 拦截(判不出是否注释就别放行)', () => { + const r = classifySourceChanges( + ['client/electron/src/new/Thing.ts'], + '/repo', + fakeRun({ 'ls-files': 'client/electron/src/new/Thing.ts' }) + ); + assert.equal(r.commentOnly, false); + assert.match(r.reason, /未跟踪/); +}); + +test('★ 没有文件更新 ⇒ 放行(空集不该拦)', () => { + const r = classifySourceChanges([], '/repo', fakeRun({})); + assert.equal(r.commentOnly, true); +}); + +// ── 路径规范化:绝对路径要能转成仓库相对 ─────────────── + +test('绝对路径会被转成仓库相对(find 给的是绝对路径)', () => { + const r = classifySourceChanges( + ['/repo/client/electron/src/api/client.ts'], + '/repo', + fakeRun({ 'ls-files': '', 'diff': '+// 只有注释\n' }) + ); + assert.equal(r.commentOnly, true); + assert.match(r.reason, /client\/electron\/src\/api\/client\.ts/); + // 不能把绝对路径整条塞进 git diff -- 之后(那会查不到任何东西 ⇒ 静默「无改动」) + assert.doesNotMatch(r.reason, /\/repo\//, 'reason 里不该出现仓库根前缀'); +}); + +/* + 变异验证(本判据必须转红,否则它分不清): + 把 isCodeChange 里的注释分支去掉(所有行都算真改动) + ⇒ 「只有注释 ⇒ 放行」那格必须红 —— 否则部署会被那一行注释继续拦。 + 把 isCodeChange 恒返回 false + ⇒ 「有代码 ⇒ 拦截」「单行改动 ⇒ 拦截」两格必须红 —— + 否则等于把 2026-09-14 那个静默失败放回来。 +*/ \ No newline at end of file diff --git a/deploy/redeploy-gateway.sh b/deploy/redeploy-gateway.sh index f86f55b..c373aca 100755 --- a/deploy/redeploy-gateway.sh +++ b/deploy/redeploy-gateway.sh @@ -147,13 +147,34 @@ if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/client/electron/dist/assets" ]; then # 部署脚本照样"同步成功",服务出去的还是旧 bundle —— 表现为接口 404 # (/api/v1/api/v1/… 双前缀),而所有单测都是绿的。 if [ -d "$REPO/client/electron/src" ] && [ -d "$REPO/client/electron/dist" ]; then - newer=$(find "$REPO/client/electron/src" -type f -newer "$REPO/client/electron/dist/index.html" 2>/dev/null | head -3) + newer=$(find "$REPO/client/electron/src" -type f -newer "$REPO/client/electron/dist/index.html" 2>/dev/null | head -20) if [ -n "$newer" ]; then - echo " [FAIL] 前端 dist 比源码旧(先跑:cd client/electron && npm run build)" - echo " 更新的文件:$(echo "$newer" | tr '\n' ' ')" - exit 1 + # ★ 2026-10-02:mtime 只能说明「文件被碰过」,不能说明「它变了什么」。 + # + # 共享工作树里别人改一行**注释**就会卡住部署 —— 那行注释不进 bundle, + # 重建产物与现有 dist 逐字节相同。实测同一天因此多花了两轮 npm run build + # (client/electron/src/api/client.ts 的线程树说明,另一会话的在制品)。 + # + # 但 mtime 那道闸不能拆:2026-09-14 改了 appearance.ts 的请求路径却没重建, + # 部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404 + # (/api/v1/api/v1/…)而**所有单测都是绿的**。那种失败是沉默的。 + # + # 所以:先问「差异是否只是注释」,是则放行,否则维持拦截。 + # 拿不准时偏向拦截(误拦 = 多构建一次;误放 = 把旧界面打进二进制)。 + verdict=$(REPO="$REPO" node "$REPO/deploy/web-comment-only.mjs" $newer 2>/dev/null) + if [ "$(printf '%s' "$verdict" | sed -n 's/.*"commentOnly":\([a-z]*\).*/\1/p')" = "true" ]; then + warn "前端源码被更新,但差异**仅为注释** ⇒ 不重建" + printf ' %s\n' "$(printf '%s' "$verdict" | sed -n 's/.*"reason":"\([^"]*\)".*/\1/p')" + warn "(产物内容不受影响;下次真改代码时仍需 npm run build)" + else + echo " [FAIL] 前端 dist 比源码旧(先跑:cd client/electron && npm run build)" + echo " 更新的文件:$(echo "$newer" | tr '\n' ' ')" + [ -n "$verdict" ] && echo " 原因:$(printf '%s' "$verdict" | sed -n 's/.*"reason":"\([^"]*\)".*/\1/p')" + exit 1 + fi + else + echo " [ OK ] 前端产物比源码新" fi - echo " [ OK ] 前端产物比源码新" fi else say "1. 跳过前端同步" diff --git a/deploy/web-comment-only.mjs b/deploy/web-comment-only.mjs new file mode 100644 index 0000000..c3d1f25 --- /dev/null +++ b/deploy/web-comment-only.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * 判定「前端源码更新是否只是注释」。 + * + * # 为什么需要它 + * + * redeploy-gateway.sh 用 mtime 比对源码与 dist: + * + * newer=$(find src -type f -newer dist/index.html) + * [ -n "$newer" ] && exit 1 + * + * 而 mtime 只说「这个文件被碰过」,不说「它变了什么」。于是**任何人在共享 + * 工作树里改一行注释就会卡住部署** —— 那行注释不进 bundle,重建产物与 + * 旧产物逐字节相同。 + * + * 实测两次(2026-10-02 同一天):`client/electron/src/api/client.ts` 的 + * 线程树说明注释(另一会话的在制品,未提交)把部署拦了两次,每次多花一轮 + * `npm run build`。 + * + * # 为什么不是「无条件跳过」 + * + * mtime 那道闸是有来历的:2026-09-14 改了 `src/lib/appearance.ts` 的请求 + * 路径却没跑 vite build,部署照样「同步成功」,出去的还是旧 bundle —— + * 表现为接口 404(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。 + * 那种失败是沉默的,所以闸不能拆。 + * + * 本模块只在「差异确为纯注释」时给出放行信号,其余一律维持拦截。 + * 拿不准时偏向拦截:误拦的代价是多构建一次(几十秒),误放的代价是 + * 把旧界面打进二进制(接口 404,且没人立刻归因)。 + * + * # 判定口径 + * + * 对每个「比 dist 新」的源码文件,取它相对 **HEAD** 的 diff,去掉 + * `---`/`+++` 头与整行注释后若还剩内容 ⇒ 是真改动 ⇒ 维持拦截。 + * + * 几个刻意的取舍: + * + * - 只看**未提交**的差异(`git diff HEAD --`)。已提交的改动早于本次 + * 部署决策,不该在这里判;否则每次部署都要把整个提交历史重扫一遍。 + * - **未跟踪的新文件**按真改动处理。判不出来就拦。 + * - 滤掉的是「整行都是注释」的行。行尾注释(`code(); // 注释`)算真改动 —— + * 保守方向,且那种改动本来也该重建。 + * - 块注释的中间行(` * …`)与结尾(星号 + 斜杠)也算注释。 + */ + +/** + * @param {string[]} files 比 dist 新的源码文件(绝对或相对仓库根) + * @param {string} cwd 仓库根 + * @param {(cmd: string[]) => {stdout: string, code: number}} run + * @returns {{commentOnly: boolean, changed: string[], untracked: string[], reason: string}} + */ +export function classifySourceChanges(files, cwd, run) { + const rel = files + .map((f) => f.startsWith(cwd) ? f.slice(cwd.length + 1) : f) + .filter(Boolean); + + if (rel.length === 0) { + return { commentOnly: true, changed: [], untracked: [], reason: '没有文件更新' }; + } + + // 未跟踪的新文件:判不出来 ⇒ 当真改动。 + const untrackedOut = run(['git', 'ls-files', '--others', '--exclude-standard', '--', ...rel]); + const untracked = splitLines(untrackedOut.stdout); + if (untracked.length > 0) { + return { + commentOnly: false, + changed: untracked, + untracked, + reason: `新增未跟踪文件 ${untracked.join(' ')} —— 判不出是否只是注释,按真改动处理` + }; + } + + // 取相对 HEAD 的差异(只看工作树里还没提交的)。 + const diffOut = run(['git', 'diff', '-U0', 'HEAD', '--', ...rel]); + const significant = splitLines(diffOut.stdout).filter(isCodeChange); + + if (significant.length === 0) { + return { + commentOnly: true, + changed: [], + untracked: [], + reason: `差异仅为注释(${rel.join(' ')})—— 不进 bundle,重建产物与现有 dist 相同` + }; + } + + return { + commentOnly: false, + changed: rel, + untracked: [], + reason: `有非注释改动(${significant.length} 行,如:${significant[0]})` + }; +} + +/** 判断一行 diff 文本是否算「真代码改动」。 */ +export function isCodeChange(line) { + // ---/+++ 文件头 + if (line.startsWith('---') || line.startsWith('+++')) return false; + // diff 元信息 + if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('@@')) return false; + // 必须是增删行之一 + if (!line.startsWith('+') && !line.startsWith('-')) return false; + + const body = line.slice(1); + // 空行 + if (body.trim() === '') return false; + // 整行注释:// …、* …(块注释中间行)、* / …(块注释结尾) + if (/^\s*(\/\/|\*|\/\*)/.test(body)) return false; + return true; +} + +function splitLines(s) { + return String(s || '').split('\n').map((x) => x.trim()).filter(Boolean); +} + +// 直接执行时打印判定结果(给 shell 侧一行输出用)。 +if (process.argv[1] && process.argv[1].endsWith('web-comment-only.mjs')) { + const { execFileSync } = await import('node:child_process'); + const files = process.argv.slice(2); + if (files.length === 0) { + console.log(JSON.stringify({ commentOnly: false, reason: '没有传入文件' })); + process.exit(0); + } + const cwd = process.env.REPO || process.cwd(); + const run = (cmd) => { + try { + const stdout = execFileSync(cmd[0], cmd.slice(1), { cwd, encoding: 'utf8' }); + return { stdout, code: 0 }; + } catch (e) { + return { stdout: (e.stdout || '') + (e.stderr || ''), code: 1 }; + } + }; + const res = classifySourceChanges(files, cwd, run); + console.log(JSON.stringify(res)); +} \ No newline at end of file