From 42cb1d7aa97055527499ceeddd49f31b4f7b87ae Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 8 Aug 2026 20:36:18 +0800 Subject: [PATCH] =?UTF-8?q?console-ui=EF=BC=9A=E6=8A=8A=E3=80=8C=E7=B7=9A?= =?UTF-8?q?=E4=B8=8A=E8=B7=91=E7=9A=84=E6=98=AF=E4=B8=8D=E6=98=AF=E7=95=B6?= =?UTF-8?q?=E4=BB=A3=E7=9A=84=E3=80=8D=E8=AE=8A=E6=88=90=E6=A9=9F=E6=A2=B0?= =?UTF-8?q?=E5=88=A4=E6=BA=96=EF=BC=8C=E4=B8=A6=E6=8E=A5=E9=80=B2=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E9=8F=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-08:「已經發生過一次這個錯誤,把舊版界面上到 prod, 你要確定不可再犯。」 實測(repo 對線上,純文字資產比對): repo portal/index.html 343,969 bytes 線上 mira.uncle6.me 82,911 bytes(Songti 12 處,舊金色 serif 品牌) 線上 pages.dev 82,911 bytes(同上) 而 e730b3f 那版 verify-live 對這兩站**三項檢查全過**——因為它驗的是 組態(apiBase、profile 的 views/home),不是世代。 ⇒ 一個網址可以組態完全正確、卻對外展示一套早就被淘汰的介面, 而所有機械檢查都說它是綠的。這就是要消滅的狀態。 本次落地: 一、世代指紋(targets.mjs) 逐一取線上/產物的資產(index / portal / console / favicon.svg), 遮掉本來就該隨部署目標不同的那兩行(VIEWS/HOME),其餘按位元組比對。 刻意不用關鍵字清單——清單要人維護,而舊世代能無聲上線正是因為沒人記得維護它。 誠實 trade-off 寫在檔內:repo 改了沒部署就會判紅,那是正確的(那時線上確實不當代)。 二、宣告值真的寫進產物(收掉 e730b3f 標的 WIP) deploy.mjs 改為由 targets.mjs 產出 .staging/<目標> 再推: config.js 由宣告值即時產生、console 的 VIEWS/HOME 依 profile 覆寫, **覆寫沒命中就中止部署**;推之前回頭讀磁碟上那份驗一次(不看腳本印了什麼)。 public/config.js 刪除——它是產物不是原始碼。 三、修好一道從 08-03 起就在誤判的閘 t160 的世代閘比對 portal 全文含「登記新庫」即拒部,而 66f1b59(08-03) 加了一則**說明「已經把它拿掉了」的 HTML 註解** ⇒ 該閘自那天起每次誤判, npm run deploy:personal 連續五天推不出去。改成剝掉註解後只看可見內容, 並降級為輔助(主判準是指紋)。這正是「手工關鍵字閘會腐爛」的實例。 四、讓它在該跑的時候真的被跑到(不再生出沒人記得執行的腳本) · deploy.mjs 推完自動回頭驗線上,不過就算本次部署失敗 · .deploy-state.json 只在線上實測通過後才寫,且不進版控 (新 checkout 沒紀錄=狀態未知=該被提醒,而不是繼承別人的綠燈) · Stop hook 每回合離線比對「手上這一代 vs 最後一次驗過的部署」, 在要說「做完了」的那一刻出聲(實測 0.096s,不連網) 五、uncle6 邊界寫進工具本身(leo 08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」) deploy.targets.json 的 enterprise 標 frozen:deploy 拒絕部署、verify 連抓都不抓。 目標本身保留不刪——刪掉就變成下一個 AI 眼中「從來沒有過這個站」的失憶。 同源清掉兩處還活著的舊記錄:README 的線上 demo 連結、public/index.html 的註解。 驗收證據見 commit 後的實測輸出(舊世代樣本取自 git 歷史 ad367e4,本機起站餵判準, 未碰任何線上資源)。 Co-Authored-By: Claude Opus 5 --- .claude/hooks/stop-check-sync.sh | 27 ++++ .gitignore | 6 + README.md | 7 +- console-ui/deploy.targets.json | 24 +++- console-ui/package.json | 8 +- console-ui/public/config.js | 2 - console-ui/public/index.html | 7 +- console-ui/scripts/deploy.mjs | 108 ++++++++++----- console-ui/scripts/targets.mjs | 205 ++++++++++++++++++++++++++++- console-ui/scripts/verify-live.mjs | 181 ++++++++++++++++++++----- system-dev/wiki/mistakes.md | 37 ++++++ 11 files changed, 531 insertions(+), 81 deletions(-) delete mode 100644 console-ui/public/config.js diff --git a/.claude/hooks/stop-check-sync.sh b/.claude/hooks/stop-check-sync.sh index 6d03b5d..d569bc1 100755 --- a/.claude/hooks/stop-check-sync.sh +++ b/.claude/hooks/stop-check-sync.sh @@ -32,6 +32,33 @@ SDD 協議要求:code 和 SDD 必須同步更新。 EOF fi +# ── console-ui:對外網址上是不是還跑著舊世代?(2026-08-08)──────────────── +# +# 病(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,你要確定不可再犯」): +# 前端改完、commit 了、甚至 wiki 都寫了,但**沒有人把它推上去**—— +# 而線上不會報錯,只是繼續展示半個月前的介面。08-08 實測:三個對外網址的 +# apiBase/profile 全綠,跑的卻是 07-22 那一代。**組態對 ≠ 世代對。** +# +# 為什麼掛在 Stop:這裡正是 CC 要說「做完了」的那一刻。 +# 不連網(每回合都跑),只比對「手上這一代」與「最後一次**通過線上實測**的部署紀錄」 +# (.deploy-state.json 只在 deploy.mjs 驗過線上後才寫,不是跑過指令就寫)。 +# 要問線上真實現況:cd console-ui && npm run verify(那支才連網)。 +if [ -d console-ui/scripts ] && command -v node >/dev/null 2>&1; then + LAG="$(cd console-ui && node scripts/verify-live.mjs --offline-lag 2>/dev/null)" + if [ -n "$LAG" ]; then + cat >&2 </dev/null | head -5) if [[ -n "$TASKS_DIFF" ]]; then diff --git a/.gitignore b/.gitignore index 57492c2..425077d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,9 @@ wrangler.leo21c.toml # 2026-08-07:每次本機跑部署都會冒出來吵未推警察,且含不該進版控的鎖檔 /package.json /package-lock.json + +# console-ui 部署產物(deploy.mjs 依 deploy.targets.json 即時產生,不是原始碼) +console-ui/.staging/ +# 「上一次通過線上實測的部署」紀錄——本機事實,不隨 repo 走 +# (刻意不進版控:新 checkout 沒有紀錄 ⇒ 狀態未知 ⇒ 該被大聲提醒,而不是繼承別人的綠燈) +console-ui/.deploy-state.json diff --git a/README.md b/README.md index 27306ca..7a040d1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,12 @@ **讓 AI 用的工作流軟體(目前只支援 Claude Code)** -> 想先看用它做出來的產品?**[Arcrun RAG](https://git.uncle6.me/Leo/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫),有[線上 demo](https://rag-demo.arcrun.dev/portal) 可直接玩。 +> 想先看用它做出來的產品?**[Arcrun RAG](https://git.uncle6.me/Leo/arcrun-rag)** —— 企業知識庫(丟檔案自動長出可查詢、可問答的知識庫)。 +> +> AI 很會寫程式,就要除錯,過程浪費很多 Token 及時間,但絕大部分是重複內容,例如登入認證、存取資料庫等。 diff --git a/console-ui/deploy.targets.json b/console-ui/deploy.targets.json index 41c2743..c858441 100644 --- a/console-ui/deploy.targets.json +++ b/console-ui/deploy.targets.json @@ -18,21 +18,36 @@ " **`npm run deploy:personal` 會把個人站的 API 打到企業 demo 的後端、頁面砍成 4 頁**,", " 而終端機從頭到尾顯示『成功』。(第三次的 accountId 是靠 env 傳的,倖存;前兩次的解等於被還原。)", "", - " → 現在的規矩:**產物由 deploy.mjs 依本檔即時產生(staging 目錄),", + " → 現在的規矩:**產物由 deploy.mjs 依本檔即時產生(.staging/<目標>),", " 推之前驗產物、推之後驗線上網址**。public/ 裡不再放任何跟目標有關的值。", " · public/config.js 已刪除——它是產物不是原始碼(自架站的 /config.js 由", " arcrun-rag 的 build-ui-bundle 動態產生,不吃這個檔)", " · public/console/index.html 的 VIEWS/HOME 只是本機 preview 的預設值,", " 部署時一律被 _profiles 覆寫,覆寫沒命中就中止部署", "", + "🔴 第五次(2026-08-08 同日,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,", + " 你要確定不可再犯」):**組態對 ≠ 世代對**。", + " 當天實測:三個對外網址的 apiBase/views/home **三項全過**,", + " 但它們跑的是 07-22 那一代的 portal(82,911 bytes、舊金色 serif 品牌、Songti 12 處),", + " repo 已是 343,969 bytes 的新品牌世代。**組態全綠、介面落後半個月,沒有任何檢查會叫。**", + " → 故 verify-live 加第二層「世代指紋」:逐一抓線上資產、遮掉本來就該隨目標不同的", + " 那兩行(VIEWS/HOME),其餘按位元組比對 repo public/。", + " 不用關鍵字清單——清單要人維護,而舊世代能無聲上線正是因為沒人記得維護它。", + "", "版本差異(leo 2026-07-22 定調):頁面都存在,由 profile 決定顯示哪些。", " personal(full) 個人版:7 頁全開,落地駕駛艙", " enterprise(rag) 企業版:只留 搜尋/工作流/設定/card,落地搜尋頁", " 未來擴充:個人版新用戶上限 1、知識庫權限不可用 → 加在對應目標的欄位裡,別再散進部署指令。", "", - "用法:npm run deploy:personal / npm run deploy:enterprise", + "🧊 frozen 欄位(2026-08-08 leo 立):標了 frozen 的目標=**這個帳號的資源不歸我們動**。", + " deploy 拒絕部署它,verify 連抓都不抓(不 curl、不探測)。", + " 它不是「壞掉所以跳過」,是刻意的邊界;要解凍是人的決定(拿掉欄位並說明理由)。", + " 目標本身**保留不刪**——刪掉就變成下一個 AI 眼中「從來沒有過這個站」的失憶。", + "", + "用法:npm run deploy:personal", " npm run deploy:personal -- --dry-run (只產出並驗產物,不推)", - " npm run verify (不部署,只驗線上各站現況=宣告值)" + " npm run verify (不部署,只驗線上:組態=宣告值、世代=當代)", + " npm run verify -- --url <網址> (只問某個網址:它跑的是不是當代的)" ], "_profiles": { @@ -63,7 +78,8 @@ }, "enterprise": { - "description": "企業版 demo 站。rag-demo.arcrun.dev → uncle6 帳號 cypher。", + "frozen": "leo 2026-08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」——這站是 uncle6 帳號的資源,已廢。不更新、不下架、不探測。要動它是 leo 的閘。", + "description": "【已凍結・沿革】企業版 demo 站(uncle6 帳號)。保留紀錄用,不是現行部署對象。", "accountId": "58309bb90fd93ad6d0fe0aae99170e9d", "projectName": "arcrun-console-ui", "profile": "rag", diff --git a/console-ui/package.json b/console-ui/package.json index 68dce54..1dafbbf 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -2,11 +2,11 @@ "name": "arcrun-console-ui", "version": "0.1.0", "private": true, - "description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm,直接託管)", + "description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm);部署時由 deploy.mjs 依 deploy.targets.json 產出 .staging/<目標> 再推", "scripts": { "deploy": "node scripts/deploy.mjs", "deploy:personal": "node scripts/deploy.mjs personal", - "deploy:enterprise": "node scripts/deploy.mjs enterprise", - "preview": "npx serve public" + "verify": "node scripts/verify-live.mjs", + "preview": "node scripts/deploy.mjs personal --dry-run && npx serve .staging/personal" } -} \ No newline at end of file +} diff --git a/console-ui/public/config.js b/console-ui/public/config.js deleted file mode 100644 index dca204f..0000000 --- a/console-ui/public/config.js +++ /dev/null @@ -1,2 +0,0 @@ -// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。 -window.ARCRUN_CONFIG = { apiBase: "https://cypher.arcrun.dev" }; diff --git a/console-ui/public/index.html b/console-ui/public/index.html index e931c5a..b5d4453 100644 --- a/console-ui/public/index.html +++ b/console-ui/public/index.html @@ -7,8 +7,11 @@ 根目錄直接導向搜尋 Portal。 為什麼不做「選擇介面」的導覽頁(2026-07-21 leo 實際撞到): - 這個網域(rag-demo.arcrun.dev)是給**客戶測試**用的入口, - 客戶測試指南寫的就是「一個網址、一組帳密」——多一層選擇=多一個困惑點, + 這份 UI 部署出去的網址是給**使用者**的入口(個人站 mira.uncle6.me, + 以及自架用戶自己的網址),進站就是要能用——多一層選擇=多一個困惑點, + (2026-08-08 更正:原註解寫「這個網域=rag-demo.arcrun.dev 是客戶測試入口」, + 那是 uncle6 帳號那個已廢的 demo 站,leo 已定案不再拿它當範例; + 註解留著會把下一個人導向錯的環境,故改寫。理由本身仍然成立。) 而且會讓客戶看到 Admin Console 這個維運介面(不該對客戶露出)。 維運者要進 console 直接打 /console/ 即可。 diff --git a/console-ui/scripts/deploy.mjs b/console-ui/scripts/deploy.mjs index 9f0e26a..5726da9 100644 --- a/console-ui/scripts/deploy.mjs +++ b/console-ui/scripts/deploy.mjs @@ -1,60 +1,108 @@ /** * deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages * - * 用法:npm run deploy:personal / npm run deploy:enterprise + * 用法:npm run deploy:personal + * npm run deploy:personal -- --dry-run (只產出並驗產物,不推) * * 為什麼不直接用 `wrangler pages deploy`(2026-07-22 leo 立,實際踩到才補): * **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案** - * · leo21c → arcrun-console-ui.pages.dev(個人版 console) - * · uncle6 → 綁 rag-demo.arcrun.dev(企業版 demo 站) - * wrangler 若 OAuth 登入在 uncle6,`--project-name arcrun-console-ui` 會部到 demo 站上。 + * wrangler 若 OAuth 登入在別的帳號,`--project-name arcrun-console-ui` 會部到別人的站上。 * 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。 * * 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數—— - * 帶漏過三次:demo 站漏 profile=rag 顯示成個人版、兩站漏 apiBase 導致登入 405。 + * 帶漏過三次:漏 profile 顯示成錯的版本、漏 apiBase 導致登入 405。 + * + * 🔴 三道閘,全部**讀磁碟上真的要被推的那份**,不看本腳本自己印了什麼 + * (2026-08-08 事故的形狀正是「印的是 A、推的是 B」): + * ① 產物閘 :宣告值有沒有真的寫進產物(apiBase / VIEWS / HOME) + * ② 世代閘 :產物是不是當代(指紋+t160 的文字指紋) + * ③ 線上閘 :推完回頭抓線上,組態+世代都要對上,否則本次部署算失敗 + * 三閘都過才寫 .deploy-state.json(那份紀錄是「經過線上實測」的意思,不是「我跑過指令」)。 */ -import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { ROOT, assertArtifact, buildArtifact, loadTargets, resolveTarget, writeState } from './targets.mjs'; +import { printReport, verifyTarget } from './verify-live.mjs'; -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8')); -const names = Object.keys(targets).filter((k) => !k.startsWith('_')); +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const name = args.find((a) => !a.startsWith('--')); -const name = process.argv[2]; -if (!name || !targets[name]) { - console.error(`用法:npm run deploy:\n可用目標:${names.join(' / ')}`); - if (name) console.error(`(收到未知目標:"${name}")`); +let t; +try { + if (!name) throw Object.assign(new Error('沒有指定部署目標'), { usage: true }); + t = resolveTarget(name); +} catch (e) { + console.error(`✘ ${e.message}`); + if (e.usage) console.error(`用法:npm run deploy:\n可用目標:${loadTargets().active.join(' / ')}`); + process.exit(1); +} + +if (t.frozen) { + console.error(`✘ 目標 ${name} 已凍結,拒絕部署。\n ${t.frozen}`); + console.error(' (要解凍是人的決定:改 deploy.targets.json 拿掉 frozen 欄位,並說明理由。)'); process.exit(1); } -const t = targets[name]; console.log(`\n部署目標:${name}`); console.log(` 說明 :${t.description}`); console.log(` 帳號 :${t.accountId}`); console.log(` 專案 :${t.projectName}`); console.log(` profile :${t.profile}`); -console.log(` apiBase :${t.apiBase}\n`); +console.log(` apiBase :${t.apiBase}`); -const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId }; - -// t160(leo 07-31:「如果你會搞不清楚,就把錯的東西刪掉」):build 步驟已隨舊世代 -// src/ 一起 git rm——public/ 是唯一世代真身(手改演進),deploy=直接託管它。 -// 病史:src/(舊代 renderer 快照)與 public/(新代真身)並存,deploy 自動跑 build -// 從舊 src 重產 public ⇒ 任何一次部署都可能把 UI 打回舊世代(07-27 記帳、07-31 引爆: -// t159 重打包用了舊 public 的分支副本,leo 刷新看到被淘汰的「登記新庫」表單)。 -// 世代閘:部署前驗 public 指紋,舊世代(缺新文案/含人工建庫表單)直接拒部。 -const portalHtml = readFileSync(join(ROOT, 'public', 'portal', 'index.html'), 'utf8'); -if (!portalHtml.includes('不需要人工新增') || portalHtml.includes('登記新庫')) { - console.error('✘ 世代閘:public/portal/index.html 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕部署舊 UI。'); +// ── ①② 產出 + 驗產物 ──────────────────────────────────────────────── +const outDir = join(ROOT, '.staging', name); +try { + buildArtifact(t, outDir); +} catch (e) { + console.error(`\n✘ 產出失敗:${e.message}`); process.exit(1); } +const gate = assertArtifact(t, outDir); +console.log(`\n產物:${outDir}`); +console.log(` 世代指紋:${gate.generation.slice(0, 12)}`); +if (!gate.ok) { + console.error('\n✘ 產物閘不通過——推上去的會跟宣告的不一樣,拒絕部署:'); + for (const p of gate.problems) console.error(` · ${p}`); + process.exit(1); +} +console.log(' ✅ 產物閘:宣告值確實寫進產物,且是當代。'); + +if (dryRun) { + console.log('\n(--dry-run:到此為止,沒有推任何東西。)'); + process.exit(0); +} + +// ── 推 ─────────────────────────────────────────────────────────────── +const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId }; // --commit-dirty:本地部署常有未提交變更,不因此中斷 const deploy = spawnSync( 'npx', - ['wrangler', 'pages', 'deploy', 'public', '--project-name', t.projectName, '--commit-dirty=true'], + ['wrangler', 'pages', 'deploy', outDir, '--project-name', t.projectName, '--commit-dirty=true'], { stdio: 'inherit', cwd: ROOT, env }, ); -process.exit(deploy.status ?? 1); +if (deploy.status !== 0) { + console.error('\n✘ wrangler 部署失敗。'); + process.exit(deploy.status ?? 1); +} + +// ── ③ 線上閘 ───────────────────────────────────────────────────────── +console.log('\n── 回頭驗線上(組態+世代)──'); +const report = await verifyTarget(name, { wait: true }); +printReport([report]); +if (!report.ok) { + console.error('\n✘ 推上去了,但線上跑的 ≠ 我們手上這一份。**本次部署視為失敗**。'); + console.error(' (wrangler 說成功不代表對外網址就對——這正是要被擋掉的那個病。)'); + process.exit(1); +} + +writeState(name, { + generation: gate.generation, + apiBase: t.apiBase, + profile: t.profile, + urls: t.verifyUrls, + verifiedAt: new Date().toISOString(), +}); +console.log('\n✅ 部署完成,且線上實測=宣告值+當代世代。已記入 .deploy-state.json。'); diff --git a/console-ui/scripts/targets.mjs b/console-ui/scripts/targets.mjs index e21f132..5c33266 100644 --- a/console-ui/scripts/targets.mjs +++ b/console-ui/scripts/targets.mjs @@ -5,19 +5,28 @@ * 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算, * 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。 * 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。 + * + * 🔴 2026-08-08 第二層(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod, + * 你要確定不可再犯」):組態對 ≠ 世代對。 + * 一個網址可以 apiBase/profile 全部正確,卻對外展示一套早就被淘汰的介面, + * 而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」(見下半段): + * 把「線上這一份是不是當代的」變成一個可機械比對的值。 */ -import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +export const PUBLIC_DIR = join(ROOT, 'public'); export function loadTargets() { const raw = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8')); const profiles = raw._profiles; if (!profiles) throw new Error('deploy.targets.json 缺 _profiles(profile → views/home 對照)'); const names = Object.keys(raw).filter((k) => !k.startsWith('_')); - return { raw, profiles, names }; + const active = names.filter((n) => !raw[n].frozen); + return { raw, profiles, names, active }; } export function resolveTarget(name) { @@ -28,6 +37,9 @@ export function resolveTarget(name) { err.usage = true; throw err; } + // 凍結目標:連讀都不准碰(frozen.reason 說明是誰、何時、為什麼)。 + // 這不是「壞掉所以跳過」,是「這個帳號的資源不歸我們動」——工具自己守,不靠人記得。 + if (t.frozen) return { name, ...t, frozen: t.frozen, views: profiles[t.profile]?.views, home: profiles[t.profile]?.home }; const p = profiles[t.profile]; if (!p) { throw new Error( @@ -66,3 +78,192 @@ export function parseApiBase(text) { const m = text.match(/apiBase\s*:\s*"([^"]*)"/); return m ? m[1] : null; } + +// ───────────────────────────────────────────────────────────────────────────── +// 世代指紋(2026-08-08 第二層) +// +// 問題:verify-live 原本只驗組態(apiBase / VIEWS / HOME)。實測當天三個對外網址 +// 這三項全綠,但線上跑的是 2026-07-22 那一代的 portal(82,911 bytes、 +// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的 +// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。 +// +// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶—— +// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。 +// +// 為什麼是位元組而不是「找幾個關鍵字」: +// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線, +// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事: +// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。 +// +// 誠實的 trade-off(mindset §7,不假裝完美): +// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**, +// 因為那時線上確實不是當代的。它會吵,但吵的是真的。 +// ② 若哪天 CF 邊緣開始改寫 HTML(Rocket Loader 之類),會出現假 ❌。 +// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致), +// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查—— +// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。 +// ───────────────────────────────────────────────────────────────────────────── + +/** 納入世代指紋的資產:file=public/ 底下的路徑,urlPath=線上要抓的位址。 */ +export const GENERATION_ASSETS = [ + { file: 'index.html', urlPath: '/' }, + { file: 'portal/index.html', urlPath: '/portal/' }, + { file: 'console/index.html', urlPath: '/console/' }, + { file: 'favicon.svg', urlPath: '/favicon.svg' }, +]; + +/** + * 「本來就該隨部署目標不同」的行——比世代時遮掉,否則個人版與企業版永遠指紋不同。 + * 遮的只有這兩行;其餘全部按原樣比對。 + * config.js 整支不納入世代(它是純產物,由 apiBase 那一項單獨驗)。 + */ +const TARGET_DEPENDENT_LINES = [ + { file: 'console/index.html', re: /^[ \t]*var VIEWS = .*$/m, tag: '«VIEWS:由部署目標決定»' }, + { file: 'console/index.html', re: /^[ \t]*var HOME = .*$/m, tag: '«HOME:由部署目標決定»' }, +]; + +/** 遮掉目標相依的行。抓不到就原樣回傳(線上是舊世代時本來就可能沒有那幾行 → 該判 ❌)。 */ +export function maskTargetValues(file, bytes) { + const rules = TARGET_DEPENDENT_LINES.filter((r) => r.file === file); + if (!rules.length) return bytes; + let text = Buffer.from(bytes).toString('utf8'); + for (const r of rules) text = text.replace(r.re, r.tag); + return Buffer.from(text, 'utf8'); +} + +export function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * 由「檔名 → 位元組(抓不到給 null)」算出世代指紋。 + * @param {Array<{file:string, bytes:Buffer|null}>} entries + */ +export function fingerprintOf(entries) { + const assets = {}; + const lines = []; + for (const { file, bytes } of entries) { + if (bytes == null) { + assets[file] = { sha: null, size: null, missing: true }; + lines.push(`${file}\tMISSING`); + continue; + } + const masked = maskTargetValues(file, bytes); + const sha = sha256(masked); + assets[file] = { sha, size: Buffer.from(bytes).length, missing: false }; + lines.push(`${file}\t${sha}`); + } + return { assets, digest: sha256(Buffer.from(lines.join('\n'), 'utf8')) }; +} + +/** repo(或某個產物目錄)現在這一代長什麼樣。這就是「當代」的定義。 */ +export function generationOfDir(dir = PUBLIC_DIR) { + return fingerprintOf( + GENERATION_ASSETS.map(({ file }) => { + let bytes = null; + try { + bytes = readFileSync(join(dir, file)); + } catch { + bytes = null; + } + return { file, bytes }; + }), + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 產物:把宣告值真的寫進去(e730b3f 標的 WIP,本次收掉) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 依目標把 public/ 展開成「要推上去的那一份」。 + * 🔴 覆寫沒命中就中止——宣告了卻沒寫進產物,正是這串事故的根。 + */ +export function buildArtifact(t, outDir) { + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + cpSync(PUBLIC_DIR, outDir, { recursive: true }); + + const exp = expected(t); + + // ① config.js:產物,不是原始碼(public/ 裡不留) + writeFileSync(join(outDir, 'config.js'), exp.configJs, 'utf8'); + + // ② console 的 VIEWS/HOME:public/ 裡那兩行只是本機 preview 的預設值 + const consolePath = join(outDir, 'console', 'index.html'); + let html = readFileSync(consolePath, 'utf8'); + for (const [re, line, what] of [ + [/^[ \t]*var VIEWS = .*$/m, exp.viewsLine, 'VIEWS'], + [/^[ \t]*var HOME = .*$/m, exp.homeLine, 'HOME'], + ]) { + if (!re.test(html)) { + throw new Error( + `產物覆寫沒命中:console/index.html 找不到 ${what} 那一行 ⇒ 中止部署。\n` + + '(前端改版把那行換了寫法時會發生。宣告值寫不進去就不准推——這正是 2026-08-08 事故的形狀。)', + ); + } + html = html.replace(re, line); + } + writeFileSync(consolePath, html, 'utf8'); + + return outDir; +} + +/** + * 產物閘:推之前,回頭讀「真的要被推上去的那些檔案」,確認=宣告值。 + * 不看 deploy.mjs 自己印了什麼——只看磁碟上那份。 + */ +export function assertArtifact(t, outDir) { + const exp = expected(t); + const problems = []; + + const cfg = readFileSync(join(outDir, 'config.js'), 'utf8'); + const gotApiBase = parseApiBase(cfg); + if (gotApiBase !== t.apiBase) problems.push(`config.js 的 apiBase:宣告 ${t.apiBase},產物 ${gotApiBase}`); + + const html = readFileSync(join(outDir, 'console', 'index.html'), 'utf8'); + const gotViews = html.match(/^[ \t]*var VIEWS = .*$/m)?.[0]; + const gotHome = html.match(/^[ \t]*var HOME = .*$/m)?.[0]; + if (gotViews !== exp.viewsLine) problems.push(`console VIEWS:宣告 ${exp.viewsLine.trim()},產物 ${gotViews?.trim()}`); + if (gotHome !== exp.homeLine) problems.push(`console HOME:宣告 ${exp.homeLine.trim()},產物 ${gotHome?.trim()}`); + + // 世代閘(產物側):注入不得改動世代相關位元組 + const src = generationOfDir(PUBLIC_DIR); + const art = generationOfDir(outDir); + if (src.digest !== art.digest) { + problems.push(`產物世代指紋 ${art.digest.slice(0, 12)} ≠ public/ 的 ${src.digest.slice(0, 12)}(注入改到了不該改的位元組)`); + } + + // 世代閘(內容側,沿用 t160 的文字指紋——擋「整份 public 被換成舊代」) + // + // 🔴 只看「使用者看得到的內容」,比對前先剝掉 HTML 註解。 + // 2026-08-08 實撞:原版直接對全文比對「登記新庫」,而 66f1b59(08-03)在 portal 裡 + // 加了一則**說明「已經把登記新庫拿掉了」的註解** ⇒ 這道閘從那天起每次都誤判, + // `npm run deploy:personal` 連續五天推不出去、而錯誤訊息說的是「你的 UI 是舊代」。 + // ⇒ 手工維護的關鍵字清單會腐爛,這就是實例;世代的主判準因此改用位元組指紋, + // 這道文字閘只留來擋「整份 public 被換成舊代」,且必須剝註解才不會自傷。 + const portalRaw = readFileSync(join(outDir, 'portal', 'index.html'), 'utf8'); + const portal = portalRaw.replace(//g, ''); + if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) { + problems.push('portal/index.html 不是現行世代(可見內容缺「不需要人工新增」或仍有「登記新庫」)'); + } + + return { ok: problems.length === 0, problems, generation: art.digest }; +} + +/** 部署狀態記錄檔(只在「線上實測通過」之後才寫,見 deploy.mjs)。 */ +export const STATE_FILE = join(ROOT, '.deploy-state.json'); + +export function readState() { + try { + return JSON.parse(readFileSync(STATE_FILE, 'utf8')); + } catch { + return {}; + } +} + +export function writeState(name, record) { + const state = readState(); + state[name] = record; + writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); +} diff --git a/console-ui/scripts/verify-live.mjs b/console-ui/scripts/verify-live.mjs index 802c268..fc86e24 100644 --- a/console-ui/scripts/verify-live.mjs +++ b/console-ui/scripts/verify-live.mjs @@ -1,21 +1,38 @@ /** - * verify-live.mjs — 驗「線上網址現在真的在用的組態」=「deploy.targets.json 宣告的目標」。 + * verify-live.mjs — 驗「線上網址現在真的在跑的那一份」=「我們手上這一份」。 * * 用法: - * node scripts/verify-live.mjs 驗全部目標的全部對外網址 - * node scripts/verify-live.mjs personal 只驗某個目標 - * node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲(重試) + * node scripts/verify-live.mjs 驗全部服役中目標的全部對外網址 + * node scripts/verify-live.mjs personal 只驗某個目標 + * node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲(重試) + * node scripts/verify-live.mjs --url <網址> 只對某個網址驗世代(不需要是宣告目標) * npm run verify * - * 為什麼要這支(2026-08-08):部署腳本印出來的值,過去只是「它打算做什麼」, - * 沒有任何一步回頭確認「站上真的變成那樣」。deploy.mjs 推完會自動叫它; - * 它也可以獨立跑,用來回答「現在哪一台躺在錯的狀態」。 + * 兩層,缺一不可: + * ① 組態層:apiBase/profile 的 views/home = deploy.targets.json 宣告值 + * ② 世代層:線上資產的位元組指紋 = repo public/ 的指紋 * - * 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js 是 - * 純文字資產、VIEWS/HOME 是 HTML 內嵌常數,抓原始碼比對是「這一項」的正確驗法; + * 為什麼要第二層(2026-08-08,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod, + * 你要確定不可再犯」):當天實測三個對外網址,第一層**三項全過**, + * 而它們跑的是 07-22 那一代的 portal(82,911 bytes、金色 serif 舊品牌), + * repo 是 343,969 bytes 的新品牌世代。 + * ⇒ **組態可以完全正確,同時展示一套早就被淘汰的介面,而機械檢查一片綠。** + * 第二層就是為了讓這個狀態不可能無聲存在。 + * + * 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js/VIEWS/HOME + * 與世代指紋都是**純文字資產比對**,抓原始碼比對是這幾項的正確驗法; * 「頁面真的能用」另外走瀏覽器實載。 + * 🔴 frozen 目標(見 deploy.targets.json)連抓都不抓——不是我們的帳號,不碰。 */ -import { loadTargets, resolveTarget, parseApiBase } from './targets.mjs'; +import { + GENERATION_ASSETS, + fingerprintOf, + generationOfDir, + loadTargets, + parseApiBase, + readState, + resolveTarget, +} from './targets.mjs'; const NOCACHE = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; @@ -25,49 +42,91 @@ async function get(url) { cache: 'no-store', redirect: 'follow', }); - return { status: res.status, text: await res.text() }; + const buf = Buffer.from(await res.arrayBuffer()); + return { status: res.status, bytes: buf, text: buf.toString('utf8') }; } -/** 驗一個網址。回傳 { url, ok, checks:[{name, ok, want, got}] } */ -export async function verifyUrl(t, url) { +/** 抓線上的世代資產,算指紋。抓不到的當 MISSING(照樣算,缺檔本來就是另一代)。 */ +async function liveGeneration(base) { + const entries = []; + const detail = {}; + for (const { file, urlPath } of GENERATION_ASSETS) { + try { + const r = await get(`${base.replace(/\/$/, '')}${urlPath}`); + const ok = r.status === 200; + entries.push({ file, bytes: ok ? r.bytes : null }); + detail[file] = { status: r.status, text: ok ? r.text : null }; + } catch (e) { + entries.push({ file, bytes: null }); + detail[file] = { status: `連線失敗:${e.message}`, text: null }; + } + } + return { ...fingerprintOf(entries), detail }; +} + +/** 驗一個網址。t 給 null=只驗世代(ad-hoc 模式)。 */ +export async function verifyUrl(t, url, want) { const checks = []; + const base = url.replace(/\/$/, ''); + const live = await liveGeneration(base); + + // ── 世代層 ────────────────────────────────────────────── + const genOk = live.digest === want.digest; + const diffs = Object.entries(want.assets) + .filter(([f, a]) => live.assets[f]?.sha !== a.sha) + .map(([f, a]) => { + const l = live.assets[f] ?? {}; + const st = live.detail[f]?.status; + return `${f}:repo ${a.size ?? '缺'} bytes / 線上 ${l.missing ? `抓不到(${st})` : `${l.size} bytes`}`; + }); + checks.push({ + name: '世代', + ok: genOk, + want: `${want.digest.slice(0, 12)}(repo public/)`, + got: genOk + ? `${live.digest.slice(0, 12)}` + : `${live.digest.slice(0, 12)}\n 不同的資產:\n ${diffs.join('\n ')}`, + }); + + if (!t) return { url, ok: genOk, checks }; + + // ── 組態層 ────────────────────────────────────────────── try { - const cfg = await get(`${url}/config.js`); + const cfg = await get(`${base}/config.js`); const got = cfg.status === 200 ? parseApiBase(cfg.text) : `HTTP ${cfg.status}`; checks.push({ name: 'apiBase', ok: got === t.apiBase, want: t.apiBase, got: got ?? '(config.js 裡找不到 apiBase)' }); } catch (e) { checks.push({ name: 'apiBase', ok: false, want: t.apiBase, got: `連線失敗:${e.message}` }); } - try { - const con = await get(`${url}/console/`); - const views = con.text.match(/var VIEWS = (\[[^\]]*\]);/); - const home = con.text.match(/var HOME = "([^"]*)";/); - const gotViews = con.status === 200 ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con.status}`; - const gotHome = con.status === 200 ? (home ? home[1] : '(找不到 HOME)') : `HTTP ${con.status}`; - checks.push({ - name: `profile(${t.profile}).views`, - ok: gotViews === JSON.stringify(t.views), - want: JSON.stringify(t.views), - got: gotViews, - }); - checks.push({ name: `profile(${t.profile}).home`, ok: gotHome === t.home, want: t.home, got: gotHome }); - } catch (e) { - checks.push({ name: `profile(${t.profile})`, ok: false, want: t.profile, got: `連線失敗:${e.message}` }); - } + const con = live.detail['console/index.html']; + const conText = con?.text; + const views = conText?.match(/var VIEWS = (\[[^\]]*\]);/); + const home = conText?.match(/var HOME = "([^"]*)";/); + const gotViews = conText ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con?.status}`; + const gotHome = conText ? (home ? home[1] : '(找不到 HOME)') : `HTTP ${con?.status}`; + checks.push({ + name: `profile(${t.profile}).views`, + ok: gotViews === JSON.stringify(t.views), + want: JSON.stringify(t.views), + got: gotViews, + }); + checks.push({ name: `profile(${t.profile}).home`, ok: gotHome === t.home, want: t.home, got: gotHome }); return { url, ok: checks.every((c) => c.ok), checks }; } export async function verifyTarget(name, { wait = false } = {}) { const t = resolveTarget(name); + if (t.frozen) return { name, target: t, skipped: true, ok: true, results: [] }; + const want = generationOfDir(); const attempts = wait ? 8 : 1; let results = []; for (let i = 1; i <= attempts; i++) { results = []; - for (const url of t.verifyUrls) results.push(await verifyUrl(t, url)); + for (const url of t.verifyUrls) results.push(await verifyUrl(t, url, want)); if (results.every((r) => r.ok) || i === attempts) break; - process.stdout.write(` … 尚未生效,${5}s 後重試(${i}/${attempts - 1})\n`); + process.stdout.write(` … 尚未生效,5s 後重試(${i}/${attempts - 1})\n`); await new Promise((r) => setTimeout(r, 5000)); } return { name, target: t, ok: results.every((r) => r.ok), results }; @@ -76,12 +135,16 @@ export async function verifyTarget(name, { wait = false } = {}) { export function printReport(reports) { for (const r of reports) { console.log(`\n【${r.name}】${r.target.description}`); + if (r.skipped) { + console.log(` ⏸️ 已凍結,不抓不驗:${r.target.frozen}`); + continue; + } console.log(` 宣告:profile=${r.target.profile} apiBase=${r.target.apiBase}`); for (const u of r.results) { console.log(` ${u.ok ? '✅' : '❌'} ${u.url}`); for (const c of u.checks) { if (c.ok) console.log(` ✓ ${c.name} = ${c.got}`); - else console.log(` ✗ ${c.name}\n 宣告:${c.want}\n 線上:${c.got}`); + else console.log(` ✗ ${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`); } } } @@ -97,15 +160,61 @@ const isCli = process.argv[1] && import.meta.url === `file://${process.argv[1]}` if (isCli) { const args = process.argv.slice(2); const wait = args.includes('--wait'); + const urlIdx = args.indexOf('--url'); + + if (args.includes('--offline-lag')) { + // 不連網,只問一句:「我手上這一代,有沒有真的送出去過?」 + // 給 Stop hook 用(每回合都跑,所以不准連網、不准慢)。 + // 唯一的事實來源是 .deploy-state.json,而它**只在線上實測通過後**才被寫(見 deploy.mjs) + // ⇒ 它說綠就是真的有人驗過線上,不是「我跑過部署指令」。 + const here = generationOfDir().digest; + const state = readState(); + const stale = []; + for (const n of loadTargets().active) { + const s = state[n]; + if (!s) stale.push(`${n}:沒有任何一次通過線上實測的部署紀錄(線上是哪一代,現在沒人知道)`); + else if (s.generation !== here) { + stale.push(`${n}:最後一次驗過的是 ${s.generation.slice(0, 12)}(${s.verifiedAt.slice(0, 10)}),現在手上是 ${here.slice(0, 12)}`); + } + } + if (stale.length) { + console.log(stale.join('\n')); + process.exit(1); + } + process.exit(0); + } + + if (urlIdx !== -1) { + // ad-hoc:只問「這個網址上跑的是不是當代的」——不需要它是宣告過的目標。 + const url = args[urlIdx + 1]; + if (!url) { + console.error('用法:node scripts/verify-live.mjs --url <網址>'); + process.exit(2); + } + const want = generationOfDir(); + const r = await verifyUrl(null, url, want); + console.log(`\n【世代檢查】${url}`); + for (const c of r.checks) { + if (c.ok) console.log(` ✅ ${c.name} = ${c.got}`); + else console.log(` ❌ ${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`); + } + if (!r.ok) { + console.error('\n❌ 這個網址上跑的不是當代的前端——它展示的是一套已經被淘汰的介面。'); + process.exit(1); + } + console.log('\n✅ 這個網址上跑的=我們手上這一份。'); + process.exit(0); + } + const picked = args.filter((a) => !a.startsWith('--')); const names = picked.length ? picked : loadTargets().names; const reports = await verifyAll(names, { wait }); printReport(reports); const bad = reports.filter((r) => !r.ok); if (bad.length) { - console.error(`\n❌ ${bad.length} 個目標的線上組態與宣告不符:${bad.map((b) => b.name).join('、')}`); - console.error(' (站上實際在用的值 ≠ deploy.targets.json 宣告的值——這正是要被擋掉的那個病)'); + console.error(`\n❌ ${bad.length} 個目標與宣告/當代不符:${bad.map((b) => b.name).join('、')}`); + console.error(' (線上實際在跑的 ≠ 我們手上這一份——這正是要被擋掉的那個病)'); process.exit(1); } - console.log('\n✅ 所有目標:線上組態=宣告值。'); + console.log('\n✅ 所有服役中目標:線上組態=宣告值,線上世代=repo 當代。'); } diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index 68e186c..3bfe587 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -512,6 +512,41 @@ Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,** --- +## 25. 組態全綠,介面卻是舊世代——「驗了組態」被當成「驗了線上」(2026-08-08) + +**leo 原話**:「已經發生過一次這個錯誤,**把舊版界面上到 prod,你要確定不可再犯**。」 + +**現場**:實測三個對外網址,`apiBase` / `profile.views` / `profile.home` **三項全過**, +而它們跑的是 07-22 那一代的 portal(82,911 bytes、舊金色 serif 品牌、`Songti` 12 處); +repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。 +⇒ **一個網址可以組態完全正確、同時對外展示一套早就被淘汰的介面,而所有機械檢查都說它綠。** + +**兩個根因,分開記**: + +1. **驗證的維度少了一個**。組態(連去哪、開哪幾頁)與世代(跑的是哪一版前端)是**兩件事**, + 只驗前者會得到有害的綠燈——它讓人以為驗過了。 + 解:`console-ui/scripts/verify-live.mjs` 加第二層「世代指紋」=逐一抓線上資產、 + 遮掉本來就該隨部署目標不同的那兩行(VIEWS/HOME),**其餘按位元組比對 repo `public/`**。 + 位元組比對是刻意的:**不用關鍵字清單**——清單要人維護,而舊世代能無聲上線, + 正是因為沒有人記得維護它。 + +2. **手工維護的關鍵字閘會腐爛,而且會反過來咬你**。t160 那道世代閘寫的是 + 「portal 全文含『登記新庫』就拒部」。08-03(`66f1b59`)有人在 portal 加了一則 + **說明「已經把登記新庫拿掉了」的 HTML 註解** ⇒ 這道閘從那天起每次都誤判, + `npm run deploy:personal` 連續五天推不出去,而錯誤訊息說的是「你的 UI 是舊代」。 + 解:文字閘比對前先剝掉 HTML 註解(只看使用者看得到的內容),並降級成輔助——主判準是指紋。 + +**判準(下次照用)**:問「線上這一份**是不是我們手上這一份**」, +不要問「線上這幾個設定值對不對」。前者一句話涵蓋後者答不出來的東西。 + +**還有一半是「有沒有人記得跑」**。再好的檢查放在沒人執行的腳本裡等於不存在(這個 repo 已有數支那種)。 +故三處接死:① `deploy.mjs` 推完自動回頭驗線上,不過就算本次部署失敗; +② `.deploy-state.json` **只在線上實測通過後**才寫(不是跑過指令就寫); +③ Stop hook(`stop-check-sync.sh`)每回合離線比對「手上這一代 vs 最後一次驗過的部署」, +在 CC 要說「做完了」的那一刻出聲。 + +--- + ## 快速檢查清單(做新功能前) - [ ] 這是工作流還是零件?問「有必要嗎?」 @@ -531,3 +566,5 @@ Workers Secret 也還在)。正解=刪除鈕標「即將開通」等 T9,** - [ ] 退役/降級某零件?同步清「AI 搜尋零件的三個源」=示例 yaml + parts.ts 硬編碼清單 + (validate 跳過是設計);別只改一處宣布完成(#22) - [ ] 沒對應 recipe?誠實留 TODO + 發 issue 補 seed,別硬塞語意不符的 canonical_id 充數(假綠,#22) - [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23) +- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25) +- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25)