## 起因(实测,同一天两次)
`redeploy-gateway.sh` 用 mtime 比对源码与 dist:
newer=$(find src -type f -newer dist/index.html)
[ -n "$newer" ] && exit 1
mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。
2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。
## 为什么不是拆掉那道闸
2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。
本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:
误拦的代价 = 多构建一次(几十秒)
误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)
## 判定口径(deploy/web-comment-only.mjs)
对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。
- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。
## 判据(7 格)
`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。
**变异验证**:
去掉注释分支(所有行算真改动) → 红 7(部署会被那一行注释继续拦)
isCodeChange 恒 false → 红 7(把 2026-09-14 的静默失败放回来)
第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。
## 真实场景验证(不是只跑单测)
把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
⇒ commentOnly=true,理由写明「差异仅为注释」
临时往 sse.ts 追加一行真代码
⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
bash deploy/redeploy-gateway.sh --dry-run
⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建
## 顺带
`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
142 lines
6.1 KiB
JavaScript
142 lines
6.1 KiB
JavaScript
/**
|
||
* `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 那个静默失败放回来。
|
||
*/ |