Skip to content

feat!: 年份支援改為 runtime 探測,並修正兩個統計錯誤 (2.0.0) - #4

Merged
lis186 merged 4 commits into
mainfrom
feat/2.0.0-runtime-year-probing
Aug 2, 2026
Merged

lis186 merged 4 commits into
mainfrom
feat/2.0.0-runtime-year-probing

Conversation

@lis186

@lis186 lis186 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

為什麼

上游 ruyut/TaiwanCalendar 早已發布 2027 資料,但本套件把「支援到哪一年」寫死成 2026,導致查不到,已延誤約半年。

資料本來就是 runtime 從 CDN 抓的、不打包進套件 —— 把「有哪些年份」打包進發布物是設計錯誤。寫死下限(2017,上游起點)合理;寫死上限不合理。

順著這條線查出同一病灶的多個變體,以及兩個長期存在的統計錯誤。

破壞性變更

變更 影響
移除公開匯出 SUPPORTED_YEAR_RANGE 改用 MIN_SUPPORTED_YEAR + getMaxQueryableYear()。原本的 end 是個謊 —— 它宣稱是「支援上限」,實際只是打包當時的寫死值
HolidayStats.nationalHolidays 語意變更 只計具名國定假日。2026 年從 114 變成 16(另 98 個是週末,移入新欄位 weekends)
stats 的 holidayTypes 數值變更 先前在全部 11 個年度都是錯的(見下)

版號 1.0.1 → 2.0.0。MAJOR 在 cli / mcp / skills 三個專案間共享,代表同一世代;MINOR/PATCH 各自獨立。

破壞性判定不是主觀的 —— 寫一份「舊消費者」檔 import { SUPPORTED_YEAR_RANGE } 對打包產物跑 tsc:舊 dist exit 0、新 dist TS2305 has no exported member。

主要變更

年份改為 runtime 探測 — 收到年份就向上游探測,有資料就能查;上游發布新年度不需更新本套件。只有 HTTP 404 代表「上游沒有這一年」;網路故障、超時、5xx 一律往外拋。

未來年度 404 與歷史年度 404 分開處理:前者是「尚未發布」(可安全跳過),後者代表上游把已發布資料弄掉了 —— 靜默跳過會讓使用者拿到不完整答案卻以為完整。

stats 兩個錯誤 — holidayTypes 一個 map 混「分類桶」與「具體假日名」兩種語意,當上游 description 剛好等於桶名時被加兩次。用真實資料驗證:舊邏輯在全部 11 個年度(2017–2027)都算錯,碰撞的 key 有兩個 —— 補假(10 個年度)與 調整放假(2017–2023)。另修 nationalHolidays 把週末算成國定假日。

移除 386 行死碼 — year-service.ts 本意是動態探索年份,但零呼叫端、端點 URL 不存在(實測連線失敗),而它的 14 個測試全綠是因為 mock 餵入真實端點從未回傳的形狀。

新增上游哨兵 — 每月驗證上游結構與內容(型別、逐日連續、星期比對、合理區間),並偵測年度倒退。13 條測試涵蓋 10 種畸形資料。

工具鏈補洞 — npm run lint 原本 exit 127(eslint 不在 devDependencies);npm run typecheck 從未檢查測試碼(主 tsconfig 的 exclude 含 tests,而 vitest 用 esbuild 剝型別不檢查 —— 連 satisfies HolidayStats 都只是裝飾品)。

驗證

依「差異檢查」原則:同一測試在舊碼 FAIL、新碼 PASS。

主張 舊碼實際訊息
補假不重複計數 expected 4 to be 2
調整放假不重複計數 expected 7 to be 4
國定假日桶不含週末 expected 3 to be 2
2027 不該被程式碼擋掉 ValidationError: 年份 2027 超出支援範圍
網路故障應拋錯 promise resolved "[2017…2026]" instead of rejecting
npm run lint 可執行 exit 127

Golden acceptance(真實上游 11 年):舊統計邏輯 11/11 年皆錯、新邏輯 0/11 錯。CLI 輸出與獨立數出的 ground truth 完全一致(2026: 具名 16 / 週末 98 / 補假 6 = 120)。

發佈後行為:npm pack → 安裝到乾淨臨時專案 → npx holiday --version 回 2.0.0、years 回 2017-2027、stats 2026 三個數字全對。

離線行為(複製真實 dist、CDN 換成不可解析主機、清快取):years 從「印出 2017-2026 並 exit 0」變成 exit 1 + 明確錯誤。

目前狀態:typecheck(含測試)0 錯誤、lint exit 0、333 測試通過、canary 對真實上游 11 年通過。

誠實標示:未驗證的部分

  • CI workflow 從未實際執行。YAML 與 shell 只做靜態檢查(python3 yaml、shellcheck);gh issue create、GITHUB_STEP_SUMMARY 渲染、以及排程觸發都要 merge 後 workflow_dispatch 才算驗證。cron 是否按月觸發無法在合理時間內驗證。
  • 7 個 explicit-function-return-type warning 未處理(該規則本身設為 warn;已確認 lint script 無 --max-warnings,CI 不會因此紅)。
  • 24h 快取 TTL 的真實時間軸未驗,只用假時鐘驗邏輯。
  • getMaxQueryableYear() = 今年+5 的跨年翻轉無測試。
  • 未來年度倒退不在 runtime 擋,只靠 canary 月檢(最多延遲一個月發現)。這是刻意取捨:runtime 保存 last-known 會引入跟上游可能不一致的永久狀態,且使用者清快取後保護就消失。
  • 四個 commit 的中間狀態未逐一執行測試(刻意讓各 commit 的檔案集合互斥以保持自成一致),但 HEAD 完整驗過。

已知待辦(不在本 PR)

  • holidayTypes 一個 map 混兩種語意的結構問題只做防禦性修補,未拆成兩個欄位
  • stats 的 holidayTypes.補假 與 compensatoryDays 現已一致,但兩者仍是重複資訊
  • skills repo 的文件與 ~/.agents/ 孤兒副本需在 npm 上架後更新

🤖 Generated with Claude Code

Justin Lee and others added 4 commits August 2, 2026 22:25
YearService 本意是動態探索上游可用年份以取代硬編碼常數(PRD 10.4),
但它從未被任何生產程式碼呼叫,也未從 src/index.ts 對外匯出 ——
真正生效的年份驗證始終是 SUPPORTED_YEAR_RANGE。

它請求的端點也不存在:data.jsdelivr.net/gh/.../data/ 實測連線失敗,
jsDelivr 的資料 API 在 data.jsdelivr.com/v1/packages/...。

14 個測試全數通過,是因為 vi.mock('ofetch') 餵入手寫的 {files: [...]}
形狀 —— 真實端點從未回傳過那個形狀。測試綠燈保護的是一段連不上線的死碼。

淨刪 386 行。呼叫端數量已用 grep 全 repo 確認為 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
年份支援改為 runtime 探測後,上游發布新年度不需要改程式碼,
所以真正需要監測的是「上游結構或內容悄悄變了」—— 那會讓探測
給出看似正常但錯誤的答案,比明確失敗難發現得多。

scripts/upstream-validate.mjs 驗證每年度資料:
- 陣列型別、空陣列早退、元素為非 null 物件
- 四個欄位存在且型別正確(不只驗存在性 —— description 變 null
  會讓 runtime 的 .includes() 炸,而只驗存在性的哨兵照樣綠燈)
- 逐日連續(防「用無效日期湊筆數」)
- 星期依日期計算後比對(只驗值域抓不到整體錯位)
- 放假天數落在 100-140 合理區間

驗證邏輯抽成獨立模組以便測試(CONTRIBUTING.md 要求所有新程式碼有測試)。
13 條測試涵蓋 10 種畸形資料與台北跨年邊界。

workflow 每月執行,並偵測年度倒退:上游若刪掉已發布的年度,
探測只會安靜地回報較短範圍(對哨兵而言 404 是正常終點)。
以帶 upstream-year label 的 issue 標題當「曾可用」的歷史紀錄。

不使用 --app / --author 過濾:實測 `gh issue list --app <不存在>` 與
`--author app/<不存在>` 都會被靜默忽略(回傳筆數等於不過濾),
只有 --label 確實生效。label 本身即有權限保護。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BREAKING CHANGE: 移除公開匯出 SUPPORTED_YEAR_RANGE;
HolidayStats.nationalHolidays 語意變更並新增 weekends 欄位。

## 年份不再寫死在套件裡

上游早已發布 2027 資料,但 SUPPORTED_YEAR_RANGE.end 寫死 2026 擋住了它,
延誤約半年。資料本來就是 runtime 從 CDN 抓的,把「有哪些年份」打包進
套件是設計錯誤 —— 寫死下限(2017)合理,寫死上限不合理。

改為:收到年份就向上游探測,有資料就能查。上游發布新年度不需更新本套件。
- MIN_SUPPORTED_YEAR + getMaxQueryableYear()(今年+5,僅合理性防護)
- HolidayRepository.getAvailableYears() 逐年 HEAD 探測,結果快取 24h
- 只有 HTTP 404 代表「上游沒有這一年」;網路故障、超時、5xx 一律往外拋

未來年度 404 與歷史年度 404 分開處理:前者是「尚未發布」(可安全跳過),
後者代表上游把已發布資料弄掉了,靜默跳過會讓使用者拿到不完整的答案卻
以為完整。新增 YearNotPublishedError 型別供呼叫端區分,並在 service 層
保留該型別(原本會被重新包成 ServiceError 而抹掉)。

available_years 這個快取 key 關閉 useCacheOnError:對假期資料而言失敗時
回傳過期內容是合理降級,但對「可查哪些年份」而言那是把網路故障偽裝成
一個看似正常的舊答案。

## stats 的兩個錯誤

1. holidayTypes 一個 map 混「分類桶」與「具體假日名」兩種語意,當上游的
   description 剛好等於分類桶名稱時被加兩次。用真實資料驗證:舊邏輯在
   全部 11 個年度(2017-2027)都算錯,碰撞的 key 有兩個 ——
   補假(10 個年度)與調整放假(2017-2023)。

2. nationalHolidays 把 description 為空的一般週末也算成國定假日。
   2026 年因此回報 114,實際具名國定假日只有 16 個(另 98 個是週末)。
   週末移入新欄位 weekends,simple/table 輸出一併顯示。

新增不變式測試(分類桶 === 對應的頂層計數器),它在不指名任何 key 的
情況下就能抓到全部碰撞 —— 先前只針對「補假」的枚舉式測試漏掉了
「調整放假」(2019 年的補假筆數為 0,那年只錨定補假的測試不會紅)。

## 版本號單一來源

CLI_VERSION 寫死 1.0.1、health 命令回報 1.0.0、package.json 是 1.0.1 ——
三方已經不同步。改為從 package.json 讀取。

## 工具鏈

- 補上缺失的 eslint devDependency 與設定(npm run lint 原本 exit 127),
  清掉 7 個既有錯誤,並移除 ci.yml 中 lint job 的 continue-on-error
- 新增 tsconfig.tests.json:主 tsconfig 的 exclude 含 tests,所以
  npm run typecheck 從未檢查測試碼,而 vitest 用 esbuild 剝型別也不檢查
  (連 satisfies HolidayStats 都只是裝飾品)。用 extends 而非複製設定。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PRD 10.4「年份支援架構建議」原標「✅ 已完成」,但那個結論不成立:
YearService 從未被呼叫、端點 URL 不存在、14 個測試靠 mock 假綠。
已改為「⚠️ 原方案未生效」並附「當時記載 vs 實際狀況」對照表。

教訓寫進 PRD:「模組已建立 + 測試通過 + 覆蓋率達標」三者同時成立,
仍不足以證明功能生效 —— 缺的是「有沒有任何生產路徑真的走過它」,
一次 grep 呼叫端就能發現。

其餘:
- 支援年份改為「2017 起,上限由上游決定」
- years / stats / next 範例改用實際輸出(原本 stats 範例的數字是編的,
  跑真實指令發現 14/100 而非 16/98)
- next 的「已達支援年份上限」示例(該行為已不存在)重寫
- 錯誤前綴由「服務錯誤」改為「資料錯誤」(型別保留後的實際輸出)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lis186
lis186 merged commit 097b203 into main Aug 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant