Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,3 @@ jobs:

- name: Lint
run: npm run lint
continue-on-error: true
130 changes: 130 additions & 0 deletions .github/workflows/upstream-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Upstream Canary

# 年份支援是 runtime 探測的,新年度不需要改程式碼就會自動生效。
# 這支排程要抓的是上游結構/內容悄悄變了、已發布年度倒退,以及通知新年度已發布。

on:
schedule:
# 每月 8 號 04:17 UTC(刻意避開整點)
- cron: '17 4 8 * *'
workflow_dispatch:

permissions:
contents: read
issues: write

# 重疊執行會讓兩個 run 同時看到「尚未通知」而各開一個 issue。
# 不取消進行中的 run —— 讓它跑完才不會留下半途的狀態。
concurrency:
group: upstream-canary
cancel-in-progress: false

env:
# 通知 issue 的專用 label。倒退偵測只認帶此 label 的 issue,
# 否則任何人開一個「上游已發布 9999 年度資料」就能污染基準。
#
# 只用 label、不用 --app/--author:實測 `gh issue list --app <不存在的名稱>`
# 與 `--author app/<不存在的名稱>` 都會被**靜默忽略**(回傳筆數與不過濾相同),
# 拿它們當安全過濾只是虛假保障。而 `--label <不存在>` 確實回 0 筆。
# label 本身即有權限保護:外部使用者無法為自己開的 issue 加 label。
ANNOUNCE_LABEL: upstream-year

jobs:
canary:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22

- name: Check upstream data
id: canary
shell: bash
run: |
# pipefail 是必要的:少了它,node 失敗會被 tee 的成功退出碼蓋掉
set -eo pipefail
node scripts/upstream-canary.mjs 2>&1 | tee canary.txt
echo "year=$(grep '^NEWEST_YEAR=' canary.txt | cut -d= -f2)" >> "$GITHUB_OUTPUT"

# 必須 always():上游異常時最需要看這份輸出,而 set -e 會讓上一步在
# 寫入 summary 之前就中止。少了這步,最需要診斷的時候 summary 是空的。
- name: Publish canary output to run summary
if: always()
shell: bash
run: |
if [ -f canary.txt ]; then
cat canary.txt >> "$GITHUB_STEP_SUMMARY"
else
echo '哨兵沒有產生任何輸出 —— 腳本可能在啟動前就失敗了。' >> "$GITHUB_STEP_SUMMARY"
fi

# 通知歷史只讀一次,供下面兩步共用 —— 過濾條件寫兩遍就會漂移。
- name: Read announcement history
id: history
shell: bash
env:
GH_TOKEN: ${{ github.token }}
YEAR: ${{ steps.canary.outputs.year }}
run: |
set -eo pipefail
# 確保 label 存在;--force 在已存在時更新而非失敗
gh label create "${ANNOUNCE_LABEL}" --force \
--description '上游年度發布通知(由 upstream-canary 自動建立)' --color 0e8a16

# gh 失敗必須讓整步失敗,不能被當成「沒有歷史紀錄」
titles=$(gh issue list --state all --limit 500 \
--label "${ANNOUNCE_LABEL}" \
--json title --jq '.[].title')

highest=$(printf '%s\n' "${titles}" \
| sed -n 's/^上游已發布 \([0-9]\{4\}\) 年度資料$/\1/p' \
| sort -n | tail -1)
echo "highest=${highest}" >> "$GITHUB_OUTPUT"

if printf '%s\n' "${titles}" | grep -Fxq "上游已發布 ${YEAR} 年度資料"; then
echo "announced=true" >> "$GITHUB_OUTPUT"
else
echo "announced=false" >> "$GITHUB_OUTPUT"
fi

# 已通知過的年度就是「曾經可用」的紀錄。用它偵測範圍倒退:
# 上游若把已發布的年度刪掉,探測只會安靜地回報較短的範圍,
# 對 canary 而言 404 是正常終點 —— 少了這一步就抓不到。
- name: Detect a regression in the available range
shell: bash
env:
YEAR: ${{ steps.canary.outputs.year }}
HIGHEST: ${{ steps.history.outputs.highest }}
run: |
set -eo pipefail
if [ -z "${HIGHEST}" ]; then
echo "尚無任何年度通知紀錄,跳過倒退檢查。"
exit 0
fi
echo "曾通知過的最新年度:${HIGHEST};本次探測到:${YEAR}"
if [ "${YEAR}" -lt "${HIGHEST}" ]; then
echo "::error::可用年份倒退:曾發布至 ${HIGHEST},現在只探測到 ${YEAR}。上游可能刪除了已發布的年度資料。"
exit 1
fi

- name: Announce a newly published year
if: steps.history.outputs.announced == 'false'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
YEAR: ${{ steps.canary.outputs.year }}
run: |
set -eo pipefail
gh issue create \
--title "上游已發布 ${YEAR} 年度資料" \
--label "${ANNOUNCE_LABEL}" \
--body "上游 ruyut/TaiwanCalendar 已提供 ${YEAR} 年度資料,哨兵驗證通過。

本套件的年份支援由 runtime 探測決定,因此不需要改程式碼即可查詢 ${YEAR} 年。
此 issue 僅作為通知,可直接關閉 —— 關閉後仍會作為「曾發布」的紀錄,
用於偵測上游年份範圍倒退。請勿修改標題或移除 ${ANNOUNCE_LABEL} label。"
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.0] - 2026-08-02

年份支援不再寫死在套件裡,並修正兩個長期存在的統計錯誤。

MAJOR 版號在 `taiwan-holiday-cli`、`taiwan-holiday-mcp`、`taiwan-holiday-skills`
三個專案間共享,代表「同一世代、互相搭配」;MINOR/PATCH 各自獨立。

### Breaking

- **移除公開匯出 `SUPPORTED_YEAR_RANGE`。** 改用 `MIN_SUPPORTED_YEAR` 與
`getMaxQueryableYear()`。原本的 `end` 欄位是個謊 —— 它宣稱是「支援上限」,
實際上只是打包當時的寫死值。
- **`HolidayStats.nationalHolidays` 的語意變更。** 過去把 `description` 為空的
一般週末也算成國定假日,2026 年因此回報 114 個「國定假日」(實際具名的只有 16 個,
另外 98 個是週末)。現在只計具名國定假日,週末移到新欄位 `weekends`。
- **`stats` 的 `holidayTypes` 數值變更**(見下方 Fixed),先前的數字在全部 11 年都是錯的。

### Added

- `weekends` 欄位與 `HOLIDAY_TYPES.WEEKEND` 分類。
- `scripts/upstream-canary.mjs` 與每月排程的 `upstream-canary` workflow:
驗證上游資料完整性(筆數、首末日、無重複、無亂序),並在上游發布新年度時開 issue 通知。

### Fixed

- **年份上限不再寫死。** 收到年份就向上游探測,上游有資料就能查 ——
上游發布新年度時**不需要更新本套件**。查詢上游尚未發布的年度會明確說明
「上游尚未發布 N 年資料」,而不是謊稱不支援。
- **`holidayTypes` 重複計數。** 分類桶與具體假日名共用一個 map,當上游的
`description` 剛好等於分類桶名稱時會被加兩次。影響 `補假`(10 個年度)與
`調整放假`(2017–2023)—— 用真實上游資料驗證,舊邏輯在**全部 11 個年度都算錯**。
- **探測失敗不再被誤判為「該年份不存在」。** 只有 HTTP 404 代表上游沒有這一年;
網路故障、超時、5xx 會明確報錯。先前斷網時會安靜回報一個較短的年份範圍
並快取 24 小時 —— 一個看起來完全正常的錯誤答案。
- `health` 命令與 `--version` 現在都從 `package.json` 讀版本。先前
`CLI_VERSION` 寫死 `1.0.1`、`health` 回報 `1.0.0`、package.json 是 `1.0.1`,三方不同步。

### Removed

- `src/services/year-service.ts` 及其測試(386 行死碼):無任何呼叫端,
且它請求的 jsDelivr 目錄 API 端點並不存在(實測連線失敗),
但 14 個測試因為 mock 掉網路而全部通過 —— 一段有綠燈保護的死碼。

### Internal

- 補上缺失的 `eslint` devDependency 與 `eslint.config.js`(`npm run lint` 先前是 exit 127)。

## [1.0.1] - 2026-01-06

### Fixed
Expand Down
85 changes: 57 additions & 28 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
| 專案名稱 | taiwan-holiday-cli |
| CLI 命令 | `holiday` |
| 資料來源 | [TaiwanCalendar](https://github.com/ruyut/TaiwanCalendar) |
| 支援年份 | 2017-2026 |
| 支援年份 | 2017 年起,上限由上游資料決定(runtime 探測,見 §10.4) |
| 版本 | 1.0.0(獨立 SemVer) |
| 授權 | MIT |

Expand Down Expand Up @@ -168,7 +168,7 @@ $ holiday range 2025-10-01 2025-10-31 --include-workdays
查詢假期統計資訊。

**參數:**
- `<year>` (必填): 年份 (2017-2026)
- `<year>` (必填): 年份,2017 年起(實際可查年份取決於上游資料)
- `[month]` (可選): 月份 (1-12)

**選項:**
Expand All @@ -178,12 +178,16 @@ $ holiday range 2025-10-01 2025-10-31 --include-workdays

```bash
$ holiday stats 2025
2025 年假期統計:
總假期天數:115 天
國定假日:113 天
補假天數:2 天
調整放假:0 天
補班天數:2 天
2025年假期統計:
- 總放假天數:118 天
- 國定假日:14 天
- 一般週末:100 天
- 補假:4 天
- 調整放假:0 天
- 補班日:1 天

# 註:2.0.0 起「國定假日」只計具名假日。先前把 description 為空的
# 一般週末也算進去,2026 年因此回報 114(實際具名的只有 16 個)。

假期類型分布:
春節:9 天
Expand All @@ -205,7 +209,7 @@ $ holiday stats 2025 10
列出指定年份的所有假期。

**參數:**
- `<year>` (必填): 年份 (2017-2026)
- `<year>` (必填): 年份,2017 年起(實際可查年份取決於上游資料)

**選項:**
- `-f, --format <format>`: 輸出格式
Expand Down Expand Up @@ -237,10 +241,10 @@ $ holiday list 2025 --holidays-only --group-by month

```bash
$ holiday years
支援的年份範圍:2017-2026
支援的年份範圍:2017-2027 (共 11 年) # ← 執行時探測所得,非寫死

可用年份:
2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026
2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027

資料來源:TaiwanCalendar
```
Expand Down Expand Up @@ -286,15 +290,15 @@ $ holiday next 3 --skip-weekends
2025-01-29 (三) 春節 - 24 天後
```

**邊界條件處理:** 當查詢超出支援年份範圍時,顯示可用資料並提示警告。
**邊界條件處理:** 搜尋上限取「上游實際有資料的最後一年」(runtime 探測所得),
所以不會查進尚未發布的年度。年末查詢時若上游尚未發布次年資料,只會顯示現有筆數。

```bash
# 假設在 2026 年 12 月執行
# 假設在 2027 年 12 月執行,而上游尚未發布 2028 年度
$ holiday next 5
接下來的 2 個假期:
2026-12-25 (五) 行憲紀念日
2026-12-26 (六) 週末
⚠ 已達支援年份上限 (2026),僅顯示 2 筆
接下來 2 個假期:
2027-12-25 (六) 行憲紀念日
2027-12-26 (日) 週末
```

---
Expand Down Expand Up @@ -701,7 +705,8 @@ $ holiday list 2025 -f json | jq '.holidays[0]'

```bash
$ holiday check 2030-01-01
錯誤:年份 2030 超出支援範圍 (2017-2026) # → stderr, exit 1
資料錯誤:上游尚未發布 2030 年資料。… # → stderr, exit 1
# 註:2030 在合理性上限(今年+5)之內,所以走的是「上游沒有」而非「超出範圍」

$ holiday check invalid-date
錯誤:無效的日期格式,請使用 YYYY-MM-DD 或 YYYYMMDD # → stderr, exit 1
Expand Down Expand Up @@ -960,7 +965,28 @@ bun build src/index.ts --compile --target=bun-windows-x64 --outfile dist/holiday
| 拆分 HolidayService | 增加複雜計算邏輯時 | 避免 God Object 反模式 |
| 引入日期庫 | 日期處理需求增加時 | 考慮 `date-fns` 或 `dayjs` |

### 10.4 年份支援架構建議 (Gemini 2026-01-05) ✅ 已完成
### 10.4 年份支援架構建議 (Gemini 2026-01-05) ⚠️ 原方案未生效,已於 2.0.0 改以另一途徑達成

> **2026-08-02 更正**:本節下方原標為「✅ 已完成」,但那個結論不成立。
> `YearService` 確實建立了、14 個測試也確實通過,然而:
>
> - **它從未被任何生產程式碼呼叫**,也未從 `src/index.ts` 對外匯出。真正生效的
> 年份驗證始終是硬編碼的 `SUPPORTED_YEAR_RANGE`(本節聲稱要移除的那個常數,
> 一直留到 2.0.0 才真的移除)。
> - **它請求的端點不存在**:`https://data.jsdelivr.net/gh/.../data/` 實測連線失敗。
> jsDelivr 的資料 API 在 `data.jsdelivr.com/v1/packages/...`,`.net` 是 CDN
> 檔案主機,沒有目錄列表 API。
> - 14 個測試全數通過,是因為它們 `vi.mock('ofetch')` 並餵入手寫的
> `{files: [...]}` 形狀 —— 真實端點從未回傳過那個形狀。**測試綠燈保護的是一段
> 連不上線的死碼。**
>
> 後果:2027 年度資料上游早已發布,三個相關 repo 卻都查不到,延誤約半年。
>
> **2.0.0 的實際做法**(見 CHANGELOG):不依賴目錄列表 API,改為對
> `<year>.json` 直接發 HEAD 探測 —— 200 代表可用、404 代表上游沒有、
> 其餘錯誤往外拋。`YearService` 及其測試已刪除(−386 行)。
>
> 以下保留原始建議內容作為歷史紀錄,不代表現行架構。

#### 問題背景

Expand Down Expand Up @@ -1026,15 +1052,18 @@ https://data.jsdelivr.net/gh/ruyut/TaiwanCalendar/data/
- **零維護**:遠端新增年份後 CLI 自動支援
- **UX 最佳**:精準的說明文字與自動完成

#### 實作結果 (2026-01-05)
#### 實作結果 (2026-01-05) —— 已於 2026-08-02 更正

| 項目 | 狀態 |
|------|------|
| `src/services/year-service.ts` | ✅ 建立 |
| `tests/unit/services/year-service.test.ts` | ✅ 14 測試 |
| 命令說明動態化 | ✅ stats, list, month, workdays |
| Shell completion 動態化 | ✅ bash, zsh, fish |
| 測試總數 | 257 passing |
| 覆蓋率 | 94.98% |
| 項目 | 當時記載 | 實際狀況 |
|------|---------|---------|
| `src/services/year-service.ts` | ✅ 建立 | 建立了,但**零呼叫端**,且端點 URL 不存在 → 2.0.0 已刪除 |
| `tests/unit/services/year-service.test.ts` | ✅ 14 測試 | 全綠,但 mock 的回應形狀真實端點從未回傳 → 假綠 |
| 命令說明動態化 | ✅ stats, list, month, workdays | 說明文字動態化了,但**年份驗證仍走硬編碼常數** |
| Shell completion 動態化 | ✅ bash, zsh, fish | 同上:候選清單由常數生成,非探索結果 |
| 測試總數 | 257 passing | —— |
| 覆蓋率 | 94.98% | 覆蓋率涵蓋了一段永不執行的模組 |

**Commit**: `560166d feat: implement YearService for dynamic year discovery (PRD 10.4)`

**教訓**:「模組已建立 + 測試通過 + 覆蓋率達標」三者同時成立,仍不足以證明功能生效。
缺的那一項是「有沒有任何生產路徑真的走過它」—— 一次 grep 呼叫端就能發現。
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,20 @@ holiday config set format json

## Supported Years

目前支援 2017-2026 年的台灣假期資料。
從 2017 年起(上游資料的起點)。**上限不寫死在套件裡** —— 可查的年份由上游
[TaiwanCalendar](https://github.com/ruyut/TaiwanCalendar) 有沒有該年度資料決定,
所以上游一發布新年度,不需要更新本套件就能查詢。

```bash
holiday years
# 支援的年份範圍:2017-2026 (共 10 年)
# 支援的年份範圍:2017-2027 (共 11 年) ← 執行時探測得到的結果
```

若查詢上游尚未發布的年度,會明確告知,而不是謊稱不支援:

```bash
holiday check 2028-01-01
# 資料錯誤: 上游尚未發布 2028 年資料。中華民國政府行政機關辦公日曆表通常在前一年年中公布。
```

## Global Options
Expand Down
Loading
Loading