Compare commits
25
Commits
a5699d1798
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d360ad266 | ||
|
|
2b61c4e235 | ||
|
|
fdd5696c7b | ||
|
|
6080cc99da | ||
|
|
43e91ceb70 | ||
|
|
c56f3271b5 | ||
|
|
52b5f08e7e | ||
|
|
957dd8795b | ||
|
|
37ecd93c6b | ||
|
|
0254c3abe1 | ||
|
|
ddc72a404e | ||
|
|
76cdedc980 | ||
|
|
744eee04ba | ||
|
|
a9ef078ef6 | ||
|
|
48b1e39e38 | ||
|
|
e0a26b81e5 | ||
|
|
280657573c | ||
|
|
8222a4043e | ||
|
|
0291002831 | ||
|
|
dd3a522b38 | ||
|
|
e98d1d0cbe | ||
|
|
378260b87a | ||
|
|
7fa7f7e1de | ||
|
|
437896348a | ||
|
|
a944141109 |
@@ -28,6 +28,8 @@
|
|||||||
## 慣例
|
## 慣例
|
||||||
|
|
||||||
- **零外部套件**:`package.json` 不得出現 `dependencies` 或 `devDependencies`。測試用 Node 內建 `node:test` + `node:assert`。
|
- **零外部套件**:`package.json` 不得出現 `dependencies` 或 `devDependencies`。測試用 Node 內建 `node:test` + `node:assert`。
|
||||||
|
- **委派標記雙向一致**:流程正本的 `〔可委派〕` 集合必須與 `references/delegation.md` 相同;任何變更同步更新資產測試與 ADR。
|
||||||
|
- **文字編碼**:流程正本、規則正本、README、AGENTS.md 與 ADR 一律以 UTF-8 儲存,面向使用者的文字維持繁體中文。
|
||||||
- **契約以議題為正本**:腳本的 flag 介面、JSON 輸出形狀、前置檢查與路徑定位規則,正本在[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),實作時以該處為準;本檔不複寫,以免兩邊走鐘。
|
- **契約以議題為正本**:腳本的 flag 介面、JSON 輸出形狀、前置檢查與路徑定位規則,正本在[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),實作時以該處為準;本檔不複寫,以免兩邊走鐘。
|
||||||
- **測試**:`npm test`(等同 `node --test`)。測試產生的暫存一律寫到 `.tmp/`,該目錄已被 git 忽略,也不會被測試探索掃到。
|
- **測試**:`npm test`(等同 `node --test`)。測試產生的暫存一律寫到 `.tmp/`,該目錄已被 git 忽略,也不會被測試探索掃到。
|
||||||
- **不改目標專案**:本 plugin 只讀目標專案的程式碼,不寫入目標專案的 `CLAUDE.md` 或任何設定檔。
|
- **不改目標專案**:本 plugin 只讀目標專案的程式碼,不寫入目標專案的 `CLAUDE.md` 或任何設定檔。
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
- **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。
|
- **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。
|
||||||
- **產出有固定形狀**:議題、PR、報表一律套 `templates/` 的模板。
|
- **產出有固定形狀**:議題、PR、報表一律套 `templates/` 的模板。
|
||||||
|
|
||||||
> 六個流程正本都到齊了,`tea-sdlc install` 會把它們一次佈署到偵測到的平台。
|
> 六個流程正本都到齊了;`tea-sdlc install` 會把它們一次佈署到偵測到的平台。委派標記以
|
||||||
> 完整需求見[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),進度見其底下的工作包。
|
> `references/delegation.md` 為對照正本,資產測試會檢查雙向一致;流程與文件均以 UTF-8
|
||||||
|
> 儲存並維持繁體中文。進度見
|
||||||
|
> [議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1) 底下的工作包。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ tea-sdlc/
|
|||||||
├── plugin.json # Antigravity 的 plugin manifest
|
├── plugin.json # Antigravity 的 plugin manifest
|
||||||
├── package.json # npm 打包與測試入口,無任何相依套件
|
├── package.json # npm 打包與測試入口,無任何相依套件
|
||||||
├── AGENTS.md # 給 AI 助理的模組邊界與慣例
|
├── AGENTS.md # 給 AI 助理的模組邊界與慣例
|
||||||
|
├── docs/adr/ # 已接受的架構決策紀錄
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ status: accepted
|
|||||||
# 以能力描述而非工具名表達委派
|
# 以能力描述而非工具名表達委派
|
||||||
|
|
||||||
流程正本在只在意結果的步驟上標記 `〔可委派〕`,並以**能力描述**說明怎麼委派——「你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做」——而不指名任何平台的子代理工具。子代理是平台專屬能力(Claude Code 與 Codex 有,Copilot/Kiro/OpenCode 不一定),而流程正本必須保持平台中立:這是 #4 已交付並打勾的驗收標準,也是 `AGENTS.md` 的模組邊界之一。能力描述對不支援的平台是自然降級,同一份正本兩邊都讀得通,不需要維護兩份。
|
流程正本在只在意結果的步驟上標記 `〔可委派〕`,並以**能力描述**說明怎麼委派——「你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做」——而不指名任何平台的子代理工具。子代理是平台專屬能力(Claude Code 與 Codex 有,Copilot/Kiro/OpenCode 不一定),而流程正本必須保持平台中立:這是 #4 已交付並打勾的驗收標準,也是 `AGENTS.md` 的模組邊界之一。能力描述對不支援的平台是自然降級,同一份正本兩邊都讀得通,不需要維護兩份。
|
||||||
|
目前的標記集合是:`sdlc-plan` 列出九段落依據與缺漏、`sdlc-analyze` 對四份清單列出疑點與算出截止日,以及 `sdlc-feat` 把議題標題翻成英文與計算分批提交方案。分批提交的實際執行仍由主流程處理;完整對照以 `references/delegation.md` 為準。
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
|
|||||||
+36
-5
@@ -9,6 +9,8 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可
|
|||||||
|
|
||||||
## 第一段:可行性分析
|
## 第一段:可行性分析
|
||||||
|
|
||||||
|
### 1. 對四份清單列出疑點〔可委派〕
|
||||||
|
|
||||||
依序讀取並逐條對照:
|
依序讀取並逐條對照:
|
||||||
|
|
||||||
1. `references/feasibility-architecture.md`
|
1. `references/feasibility-architecture.md`
|
||||||
@@ -16,19 +18,47 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可
|
|||||||
3. `references/feasibility-data.md`
|
3. `references/feasibility-data.md`
|
||||||
4. `references/feasibility-schedule.md`
|
4. `references/feasibility-schedule.md`
|
||||||
|
|
||||||
能從程式碼查證的事項自行查證;只有需要使用者決策的事項才提問。架構、邏輯、資料、時程四類依序完成,每次只問一題,每題提供建議與手動輸入。最後輸出共識摘要、變更假設、未決事項與人天估算;摘要只印終端,不寫入 Gitea。
|
只產出可核對的疑點清單,不替使用者做決策;你的環境若能把工作交給子代理,就交出去,只把清單帶回來;不能就自己做。
|
||||||
|
|
||||||
使用者確認共識摘要後,**先列出要交付或驗收的項目,逐項向使用者確認**。使用者未確認或拒絕時,立即停止,不建立工作包、不排程、不寫入後續資料。
|
### 2. 逐題確認可行性共識
|
||||||
|
|
||||||
|
能從程式碼查證的事項自行查證;只有需要使用者決策的事項才提問。架構、邏輯、資料、時程四類依序完成,每次只問一題,每題提供建議與理由,以及手動輸入的方式。最後輸出共識摘要、變更假設、未決事項與人天估算;摘要只印終端,不寫入 Gitea。
|
||||||
|
|
||||||
|
### 交付文件判斷
|
||||||
|
|
||||||
|
使用者確認共識摘要後,讀取 `references/delivery-types.md`,列出這次需求需要交付或驗收的文件,並依序逐一詢問:
|
||||||
|
|
||||||
|
1. **需求描述概要**
|
||||||
|
2. **WBS(工作分解結構)**
|
||||||
|
3. **流程圖**
|
||||||
|
4. **甘特圖**
|
||||||
|
5. **PERT 圖**
|
||||||
|
6. **關鍵路徑圖**
|
||||||
|
7. **API 契約文件**
|
||||||
|
|
||||||
|
每一種文件都要先展示必要內容骨架,再給出是否需要的建議與理由,最後讓使用者確認、拒絕或手動調整;不得把七種文件合併成一次確認。確認結果要保留文件順序、必要內容、產出位置與 ELI5 變體規則。API 契約文件只能交付預覽或使用者確認的位置,禁止寫入目標專案 repo。
|
||||||
|
|
||||||
|
使用者未確認或拒絕任何一項時,立即停止,不建立工作包、不排程、不建立相依、不寫入 Milestone、看板或其他後續資料。
|
||||||
|
|
||||||
|
### PERT 三點估算
|
||||||
|
|
||||||
|
對每一個需要排程的工作包,分別逐題詢問樂觀時間(O)、最可能時間(M)、悲觀時間(P);每題都提供建議、理由與手動輸入方式。三個值都確認後才納入排程資料,不得用單一人天估算代替,也不得在確認前建立工作包或排程。
|
||||||
|
|
||||||
## 第二段:產生工作包
|
## 第二段:產生工作包
|
||||||
|
|
||||||
確認交付/驗收項目後,依共識切出可獨立完成的工作包,套用 `templates/work-package-issue.md`。每顆工作包的待辦與驗收都要可逐項勾選;對應已確認交付項目的待辦置於第一項。工作包整體也依交付優先排序,但不得違反先決關係。
|
確認交付/驗收項目與所有必要的 PERT 三點估算後,依共識切出可獨立完成的工作包,套用 `templates/work-package-issue.md`。每顆工作包的待辦與驗收都要可逐項勾選;對應已確認交付項目的待辦置於第一項。
|
||||||
|
|
||||||
|
工作包排序先放已確認的交付文件工作包,再放純程式碼工作包;排序只在不違反先決關係時生效,若交付文件工作包依賴其他工作包,仍以拓撲順序為準。每顆工作包只做一種交付,並在描述中保留文件類型、必要內容、產出位置與驗收方式。
|
||||||
|
|
||||||
每顆工作包先以 `scripts/issue-create.js --dry-run` 檢查,再移除旗標實跑。只使用既有標籤。建立後回報編號、標題與網址。
|
每顆工作包先以 `scripts/issue-create.js --dry-run` 檢查,再移除旗標實跑。只使用既有標籤。建立後回報編號、標題與網址。
|
||||||
|
重跑同一需求時,先以既有議題與標題查重;已存在的工作包只沿用其編號與相依,不建立重複工作包。
|
||||||
|
|
||||||
## 第三段:排程
|
## 第三段:排程
|
||||||
|
|
||||||
以 `startDate`、`days` 與 `depends` 組成計畫檔,執行 `scripts/schedule.js` 計算截止日;依序用 `issue-link.js`、`issue-update.js` 與 `project-add.js` 補上既有相依、Milestone、看板、截止日與人天估算。各腳本先 dry-run,再實跑。日期與人天是排程資料,不是耗時統計。
|
### 3. 算出截止日〔可委派〕
|
||||||
|
|
||||||
|
所有工作包建立且相依關係確認後,只依已確認的 `startDate`、`days` 與 `depends` 呼叫 `scripts/schedule.js` 計算截止日;這一步只回傳可核對的日期與相依結果,你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做。寫入議題、Milestone、看板與其他後續資料不委派。
|
||||||
|
計算完成後,依序用 `issue-link.js`、`issue-update.js` 與 `project-add.js` 補上既有相依、Milestone、看板、截止日與人天估算。各腳本先 dry-run,再實跑。日期與人天是排程資料,不是耗時統計。
|
||||||
|
|
||||||
## 邊界
|
## 邊界
|
||||||
|
|
||||||
@@ -36,4 +66,5 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可
|
|||||||
- 不建立 Milestone 或專案看板。
|
- 不建立 Milestone 或專案看板。
|
||||||
- 不修改使用者專案檔案。
|
- 不修改使用者專案檔案。
|
||||||
- 不關閉或刪除既有議題。
|
- 不關閉或刪除既有議題。
|
||||||
- 共識摘要與交付確認前不建立任何工作包。
|
- 不把 API 契約文件寫入目標專案 repo。
|
||||||
|
- 共識摘要、交付文件確認與 PERT 三點估算完成前,不建立任何工作包、不排程、不寫入後續資料。
|
||||||
|
|||||||
+29
-2
@@ -3,14 +3,41 @@ description: 僅由 /sdlc-feat 指令叫用。領取工作包、開分支、逐
|
|||||||
|
|
||||||
# sdlc-feat
|
# sdlc-feat
|
||||||
|
|
||||||
輸入一個工作包議題編號。若抽取結果有 `未處理留言數`,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述。
|
輸入一個工作包議題編號。先用 `scripts/wp-extract.js` 抽取;若抽取結果有 `未處理留言數`,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述。
|
||||||
|
|
||||||
依工作包 `repos` 逐一準備 worktree;不在主工作區切換分支。逐項完成待辦並立即勾選對應驗收。交付優先項目位於待辦第一項時先完成;仍須遵守工作包的依賴與範圍邊界。
|
依工作包 `repos` 逐一準備 worktree;不在主工作區切換分支。逐項完成待辦並立即勾選對應驗收。交付優先項目位於待辦第一項時先完成;仍須遵守工作包的依賴與範圍邊界。
|
||||||
|
|
||||||
完成後依既有 commit 分類規則提交,先用 `scripts/pr-create.js --dry-run` 檢查,再實跑開 PR。回報工作包、分支、worktree、完成待辦、commit 與 PR。
|
## 交付文件待辦分流
|
||||||
|
|
||||||
|
先把每一項待辦判定為程式碼待辦或交付文件待辦;兩者不可共用無差別的實作流程。交付文件待辦是需求描述概要、WBS、流程圖、甘特圖、PERT 圖、關鍵路徑圖或 API 契約文件等文件產出,判定依 `references/delivery-types.md` 的內容與產出位置。
|
||||||
|
|
||||||
|
### 交付前逐一確認
|
||||||
|
|
||||||
|
每一份交付文件都要分開處理。產出前先列出該類型的必要內容骨架、已知來源、未決事項、建議與理由,再一次只問一題確認內容是否齊全;未確認的文件不可標成已交付。能從需求、工作包、相依或排程重新推導的內容,保留來源與推導規則,不自行編造決策。
|
||||||
|
|
||||||
|
### 預覽能力分流
|
||||||
|
|
||||||
|
以能力描述檢查目前環境是否能把該文件即時發佈成可開啟、可分享的預覽頁,並說明判定依據;不要靠固定清單或猜測環境。確認具備能力後直接產出預覽,回報實際位置與內容摘要。無法證明具備能力、預覽不可開啟或不可分享時,停止產出並一次只問一題,由 agent 依文件、來源與限制提出建議及理由,同時保留手動輸入,不替使用者決定交付方式。
|
||||||
|
|
||||||
|
API 契約文件只能交付在預覽位置或使用者確認的位置;不得寫入目標專案 repo,也不得藉由缺少預覽能力而改寫目標專案的設定檔或文件。預覽失效時回報實際限制,不捏造網址。
|
||||||
|
|
||||||
|
### ELI5 變體
|
||||||
|
|
||||||
|
使用者要求 ELI5 時,仍保留原文件的範圍、順序、相依、例外與驗收意義;把術語換成日常說法並補必要的短解釋,不刪除技術限制。圖表要重新繪製成容易閱讀的圖片式視覺,不把 Mermaid 原碼當成交付物,也不只用一個看似精確的日期隱藏 O/M/P 不確定性。
|
||||||
|
### 把議題標題翻成英文〔可委派〕
|
||||||
|
|
||||||
|
把工作包議題標題轉成不超過 40 字元的英文 kebab slug,保留原意且不捏造新範圍;這一步只產出可驗證的 slug,你的環境若能把工作交給子代理,就交出去,只把 slug 帶回來;不能就自己做。若有兩個同樣合理的翻法,交回候選與差異,由主流程詢問使用者。
|
||||||
|
|
||||||
|
## 實作與交付
|
||||||
|
### 分批提交方案〔可委派〕
|
||||||
|
|
||||||
|
依檔案類型與變更性質計算 commit 分類、順序與每批檔案;只回傳可核對的提交方案。實際執行 `scripts/commit-split.js`、處理失敗與確認 git 歷史不委派,由主流程自己完成。
|
||||||
|
|
||||||
|
完成程式碼待辦後照既有測試與驗證慣例;文件待辦則依上述分流交付。完成後依既有 commit 分類規則提交,先用 `scripts/pr-create.js --dry-run` 檢查,再實跑開 PR。回報工作包、分支、worktree、完成待辦、commit 與 PR。
|
||||||
|
|
||||||
## 邊界
|
## 邊界
|
||||||
|
|
||||||
- 不修改工作包範圍外的檔案。
|
- 不修改工作包範圍外的檔案。
|
||||||
- 不切換主工作區分支。
|
- 不切換主工作區分支。
|
||||||
|
- 不產生固定選項清單,不把平台工具名或呼叫語法寫入流程正本。
|
||||||
- 不操作碼錶、不補登工時、不產生耗時統計。
|
- 不操作碼錶、不補登工時、不產生耗時統計。
|
||||||
|
|||||||
+36
-7
@@ -7,11 +7,35 @@ description: 僅由 /sdlc-plan 指令叫用。把一段口語需求轉成結構
|
|||||||
|
|
||||||
## 步驟
|
## 步驟
|
||||||
|
|
||||||
1. 讀齊輸入,列出九個段落中已有依據與缺漏。
|
開始前讀取 `references/requirements-discovery.md`。它是需求內容品質與建立前完整性閘門的規則正本;本流程只補充操作順序與 Gitea 交付步驟。
|
||||||
2. 一次問一題補齊缺漏;未獲回答的內容放入「未決事項」,不得自行編造。
|
|
||||||
3. 套用 `templates/requirement-issue.md`,填入總覽、背景、目標、非目標、領域名詞表、流程圖、驗收標準、影響範圍與未決事項。
|
### 1. 列出九段落依據與缺漏〔可委派〕
|
||||||
4. 用 `scripts/labels-list.js` 取得既有標籤,只能選既有標籤。
|
|
||||||
5. 寫入前先執行:
|
讀齊輸入,列出總覽、背景、目標、非目標、領域名詞表、文件、驗收標準、影響範圍與未決事項中已有依據與缺漏。對每一段不只檢查是否非空,還要依需求補全規則檢查實質內容。這一步只產出可核對的清單;你的環境若能把工作交給子代理,就交出去,只把清單帶回來;不能就自己做。
|
||||||
|
|
||||||
|
### 2. 一次問一題補齊缺漏
|
||||||
|
|
||||||
|
依需求補全規則挑出下一個最高價值的需求缺口。每次只問一題,並同時列出目前理解、缺少內容、為什麼需要,以及建議選項或短範例;明確允許使用者自由改寫答案。
|
||||||
|
|
||||||
|
能從輸入、既有議題或可取得的 repo 內容查證的事項先自行查證;只有需要需求擁有者決策的事項才提問。不得把推測或實作方案寫成已確認需求。
|
||||||
|
|
||||||
|
每次收到回答後更新工作稿,重新檢查角色、情境、行為、可觀察結果,以及主要成功情境與適用的失敗/邊界情境。未回答、「不知道」或「尚未決定」仍是缺口,繼續一次問一題;只有使用者明確確認「不適用」並說明原因,才可標記該項完成。
|
||||||
|
|
||||||
|
### 3. 建立前完整性閘門
|
||||||
|
|
||||||
|
套用 `references/requirements-discovery.md` 的完整性閘門。九段落都必須有實質內容,或由使用者確認不適用並說明原因;不得留下未回答或「尚未決定」的缺口。未通過閘門前不得套用模板、查標籤或建立議題。
|
||||||
|
|
||||||
|
### 4. 填入需求議題模板
|
||||||
|
|
||||||
|
套用 `templates/requirement-issue.md`,填入總覽、背景、目標、非目標、領域名詞表、文件、驗收標準、影響範圍與未決事項;全程使用繁體中文。
|
||||||
|
|
||||||
|
### 5. 取得既有標籤
|
||||||
|
|
||||||
|
用 `scripts/labels-list.js` 取得既有標籤,只能選既有標籤。
|
||||||
|
|
||||||
|
### 6. 試跑並建立議題
|
||||||
|
|
||||||
|
寫入前先執行:
|
||||||
|
|
||||||
```
|
```
|
||||||
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> --labels "<標籤>" --dry-run
|
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> --labels "<標籤>" --dry-run
|
||||||
@@ -19,9 +43,14 @@ node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file
|
|||||||
|
|
||||||
確認內容後移除 `--dry-run` 實跑。重跑以標題查重,不建立重複議題。
|
確認內容後移除 `--dry-run` 實跑。重跑以標題查重,不建立重複議題。
|
||||||
|
|
||||||
## 流程圖限制
|
## 文件限制
|
||||||
|
|
||||||
流程圖只保留抽象節點與邊的文字描述;本流程不產生 HTML、SVG、manifest、截圖、附件或平台 preview。
|
文件段落只填以下其中一種:
|
||||||
|
|
||||||
|
- `待 /sdlc-analyze 產生`。
|
||||||
|
- 抽象節點與邊的文字描述,不寫具體圖形語法。
|
||||||
|
|
||||||
|
plan 階段禁止產生 HTML、SVG、manifest、截圖、附件或任何平台 preview。
|
||||||
|
|
||||||
## 邊界
|
## 邊界
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,7 @@
|
|||||||
一個步驟裡只有一半合判準時,**標記照下,並在該步寫明哪一半不委派**。這比整步不標好——
|
一個步驟裡只有一半合判準時,**標記照下,並在該步寫明哪一半不委派**。這比整步不標好——
|
||||||
不標的話那一半的中間產物照樣塞滿主脈絡;也比整步委派安全,因為第四條是硬排除。
|
不標的話那一半的中間產物照樣塞滿主脈絡;也比整步委派安全,因為第四條是硬排除。
|
||||||
|
|
||||||
目前有三步是這個形狀:兩份圖解總覽(產出 HTML 可委派,寫回議題的 `issue-update` 不委派)
|
目前有一步是這個形狀:分批提交的方案計算可委派,實際跑 `commit-split.js` 不委派。
|
||||||
與分批提交(方案計算可委派,實際跑 `commit-split.js` 不委派)。
|
|
||||||
|
|
||||||
## 目前標記為〔可委派〕的步驟
|
## 目前標記為〔可委派〕的步驟
|
||||||
|
|
||||||
@@ -46,12 +45,11 @@
|
|||||||
|
|
||||||
| 正本 | 步驟 | 委派範圍 |
|
| 正本 | 步驟 | 委派範圍 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `sdlc-plan` | 產生圖解版總覽 | 產出 HTML;寫回議題不委派 |
|
| `sdlc-plan` | 列出九段落依據與缺漏 | 產出可核對的清單 |
|
||||||
| `sdlc-analyze` | 對四份清單列出疑點 | 全步 |
|
| `sdlc-analyze` | 對四份清單列出疑點 | 全步 |
|
||||||
| `sdlc-analyze` | 算出截止日 | 全步 |
|
| `sdlc-analyze` | 算出截止日 | 計算日期;寫回議題不委派 |
|
||||||
| `sdlc-analyze` | 產生分析版的圖解總覽 | 產出 HTML;寫回議題不委派 |
|
|
||||||
| `sdlc-feat` | 把議題標題翻成英文 | 全步 |
|
| `sdlc-feat` | 把議題標題翻成英文 | 全步 |
|
||||||
| `sdlc-feat` | 分批提交 | 方案計算;實際提交不委派 |
|
| `sdlc-feat` | 分批提交方案 | 方案計算;實際提交不委派 |
|
||||||
|
|
||||||
`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟:前兩者每一步都在問使用者
|
`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟:前兩者每一步都在問使用者
|
||||||
或寫入 Gitea,後者只有一支唯讀腳本,委派出去省不到什麼。
|
或寫入 Gitea,後者只有一支唯讀腳本,委派出去省不到什麼。
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 交付類型規則
|
||||||
|
|
||||||
|
這份規則是 `/sdlc-analyze` 與 `/sdlc-feat` 共用的交付文件正本。交付文件不是額外的程式碼待辦;先確認要交付哪一種文件,再依本表逐一確認內容骨架,最後才產出。
|
||||||
|
|
||||||
|
## 共通規則
|
||||||
|
|
||||||
|
- 每一種文件都要逐一確認:說明是否需要、列出必要內容骨架、給出建議與理由,再接受使用者確認或手動調整;不得把七種文件合併成一次模糊確認。
|
||||||
|
- 文件沒有足夠資料時標記未決事項,不代替使用者編造決策。能由需求、工作包、相依與排程資料重新推導的內容,優先保留來源與推導規則。
|
||||||
|
- 產出位置以本表為準。預覽能力存在時可交付可開啟、可分享的預覽;沒有預覽能力時依使用者確認的方式交付,不因缺少預覽而捏造網址或改寫目標專案。
|
||||||
|
- ELI5 變體要保留原文件的範圍、順序、相依、例外與驗收意義。可把術語換成日常說法並補一句解釋,但不能刪掉技術限制;圖表改成容易閱讀的視覺,不把 Mermaid 原碼當成 ELI5 交付物。
|
||||||
|
|
||||||
|
## 七種交付類型
|
||||||
|
|
||||||
|
### 1. 需求描述概要
|
||||||
|
|
||||||
|
- **必要內容**:一句話說明做什麼與為什麼做;背景;目標與可驗收結果;非目標;影響範圍;假設與未決事項;必要的領域名詞定義。
|
||||||
|
- **產出位置**:需求議題的結構化描述,對應 `templates/requirement-issue.md` 的段落;可另外提供預覽,但議題內仍保留可機讀的白話概要。
|
||||||
|
- **ELI5 變體**:先用一句日常語言說明問題和得到的改善,再用短句解釋必要術語;不得用願景口號取代目標、非目標或驗收條件。
|
||||||
|
|
||||||
|
### 2. WBS(工作分解結構)
|
||||||
|
|
||||||
|
- **必要內容**:可獨立交付的工作包;每個工作包的目標、範圍邊界、待辦與逐項驗收;工作包之間的先決與阻擋關係;交付文件工作包優先於純程式碼工作包,但不得違反先決關係。
|
||||||
|
- **產出位置**:工作包議題的 `待辦` 與巢狀 `驗收`,以及 `整體驗收`、`repo 列表`、`關聯` 段落;不把 WBS 寫入目標專案。
|
||||||
|
- **ELI5 變體**:把每個工作包說成一個能交付的箱子,說清楚箱子裡有什麼、完成的判準,以及哪個箱子要先完成;不得只列職責或模糊階段名稱。
|
||||||
|
|
||||||
|
### 3. 流程圖
|
||||||
|
|
||||||
|
- **必要內容**:起點、終點、主要步驟、分支條件、例外路徑與步驟間的方向;節點與邊都要能從需求或工作包驗證;超過可讀範圍時拆圖或改用文字。
|
||||||
|
- **產出位置**:交付文件預覽或使用者確認的文件位置;需求議題只保留抽象節點與邊的文字描述,不產生 HTML、SVG、附件或平台 preview。
|
||||||
|
- **ELI5 變體**:用「先做什麼、接著看什麼、遇到哪種情況走哪條路」描述;保留失敗與回復路徑,圖表視覺化時不嵌入 Mermaid 原碼。
|
||||||
|
|
||||||
|
### 4. 甘特圖
|
||||||
|
|
||||||
|
- **必要內容**:每個工作包或任務、開始日、截止日、工作日數、先決關係、交付里程碑與目前可辨識的重疊;日期必須能由排程資料重算。
|
||||||
|
- **產出位置**:排程/交付文件預覽與終端摘要;Gitea 工作包議題只保留可追蹤的截止日、里程碑與關聯,不把圖表檔寫入目標專案。
|
||||||
|
- **ELI5 變體**:把它說成一張「每件事什麼時候開始、什麼時候完成、誰要等誰」的日曆;不以顏色或位置暗示未列出的依賴。
|
||||||
|
|
||||||
|
### 5. PERT 圖
|
||||||
|
|
||||||
|
- **必要內容**:任務節點、先決關係、樂觀時間(O)、最可能時間(M)、悲觀時間(P)、期望時間與不確定性;三點估算要逐項向使用者確認,不能默認成單一工期。
|
||||||
|
- **產出位置**:排程/交付文件預覽與終端摘要;O/M/P 與推導結果是排程資料,不寫入目標專案 repo。
|
||||||
|
- **ELI5 變體**:把 O/M/P 說成最快、通常、最慢三種情況,指出哪一段最不確定;不得只報一個看似精確的日期而隱藏風險。
|
||||||
|
|
||||||
|
### 6. 關鍵路徑圖
|
||||||
|
|
||||||
|
- **必要內容**:完整相依網路、每個節點的工期、最長路徑、路徑總工期、關鍵任務與可用浮時;若有多條同長路徑要全部列出;相依成環要先報錯。
|
||||||
|
- **產出位置**:排程/交付文件預覽與終端摘要;工作包議題保留相依關係與截止日作為可重算來源,不把圖表檔寫入目標專案。
|
||||||
|
- **ELI5 變體**:說明「哪一串事情任何一件延遲都會讓最後交付延遲」,同時列出不在關鍵路徑上的緩衝;不得把所有工作都稱為關鍵。
|
||||||
|
|
||||||
|
### 7. API 契約文件
|
||||||
|
|
||||||
|
- **必要內容**:介面名稱、用途、產出者、消費者、輸入與輸出形狀、成功與錯誤情境、相容性限制、可驗收範例與來源;只記錄已確認的契約,未知內容列為未決事項。
|
||||||
|
- **產出位置**:預覽或使用者確認的交付文件位置,以及可由需求議題、工作包與實作重新產生的摘要;**禁止把 API 契約文件寫入目標專案 repo**,也不得自動修改目標專案的設定檔或文件。
|
||||||
|
- **ELI5 變體**:把 API 說成「誰用什麼資料提出請求,會拿到什麼回覆,出錯時會收到什麼」;保留欄位名稱、資料型別、錯誤碼與相容性限制,不用白話改寫掉可執行的契約。
|
||||||
|
|
||||||
|
## 確認與重產
|
||||||
|
|
||||||
|
- 每份文件產出前都先展示該類型的必要內容骨架,逐一確認內容是否齊全;使用者拒絕或未確認時不把它標成已交付。
|
||||||
|
- 文件摘要必須保留來源議題、工作包、相依與排程資料的指向。來源更新後,摘要可由同一份來源重新產生,不以手工複製的摘要作為唯一真相。
|
||||||
|
- 預覽失效、不可分享或環境不具備預覽能力時,回報實際能力與限制並逐題詢問交付方式;不得回退成寫入目標專案 repo,尤其是 API 契約文件。
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 需求補全規則
|
||||||
|
|
||||||
|
這份文件是 `/sdlc-plan` 判斷需求內容是否足夠清楚、可驗收的規則正本。
|
||||||
|
|
||||||
|
## 需求的四個必要維度
|
||||||
|
|
||||||
|
每個需要實作的需求都要能回答:
|
||||||
|
|
||||||
|
1. **角色** — 誰需要這個結果,或誰執行這個行為?
|
||||||
|
2. **情境** — 在什麼觸發條件、前置條件或使用情境下發生?
|
||||||
|
3. **行為** — 角色做了什麼,或系統需要處理什麼?
|
||||||
|
4. **可觀察結果** — 外部使用者、呼叫端或驗收者能觀察到什麼結果?
|
||||||
|
|
||||||
|
只寫功能名稱、畫面名稱、API 名稱或實作方法,不足以視為完成。
|
||||||
|
|
||||||
|
## 情境覆蓋
|
||||||
|
|
||||||
|
- 至少確認主要成功情境。
|
||||||
|
- 若需求有權限、取消、重試、空值、重複執行、並行、極大量或外部來源失敗等可能性,逐一確認適用的失敗或邊界情境。
|
||||||
|
- 不適用的情境必須由使用者明確確認「不適用」,並說明原因;agent 不得自行判定。
|
||||||
|
|
||||||
|
## 提問格式
|
||||||
|
|
||||||
|
一次只問一個最高價值的缺口。每題依序提供:
|
||||||
|
|
||||||
|
- **目前理解**:根據輸入與已確認回答整理的一句話。
|
||||||
|
- **缺少內容**:指出哪個必要維度或情境仍不清楚。
|
||||||
|
- **為什麼需要**:說明它會影響哪個目標、範圍或驗收結果。
|
||||||
|
- **建議回答**:提供具體選項或短範例。
|
||||||
|
- **自由回答**:明確允許使用者改寫或提供其他答案。
|
||||||
|
|
||||||
|
建議選項是協助理解,不是替使用者做決策。
|
||||||
|
|
||||||
|
## 查證與提問邊界
|
||||||
|
|
||||||
|
- 能從輸入、既有議題或可取得的 repo 內容查證的事項,先自行查證。
|
||||||
|
- 只有需要需求擁有者決策的事項才提問。
|
||||||
|
- 不把架構、資料表、模組或其他實作方案當成需求答案;這些交給 `/sdlc-analyze`。
|
||||||
|
- 不把 agent 的推測寫成使用者已確認的需求。
|
||||||
|
|
||||||
|
## 回答狀態
|
||||||
|
|
||||||
|
- 具體回答可填入工作稿,並重新檢查所有必要維度與情境。
|
||||||
|
- 使用者明確確認「不適用」且提供原因,可標記該項已完成。
|
||||||
|
- 未回答、拒絕回答、「不知道」或「尚未決定」都仍是缺口,不得填成已確認。
|
||||||
|
- 仍有缺口時不得建立需求議題;應繼續一次問一題。
|
||||||
|
|
||||||
|
## 建立前完整性閘門
|
||||||
|
|
||||||
|
建立需求議題前必須確認:
|
||||||
|
|
||||||
|
- 九個段落都有實質內容,或使用者已確認不適用並說明原因。
|
||||||
|
- 需求的角色、情境、行為與可觀察結果完整。
|
||||||
|
- 主要成功情境已確認;適用的失敗與邊界情境已確認。
|
||||||
|
- 沒有未回答或「尚未決定」的阻塞缺口。
|
||||||
|
- 驗收標準描述外部可觀察結果,不綁定不必要的實作細節。
|
||||||
|
- 內容前後一致,且非目標足以限制範圍。
|
||||||
|
|
||||||
|
通過閘門後才可套用需求議題模板、查標籤與建立議題。
|
||||||
@@ -31,16 +31,15 @@ main(async () => {
|
|||||||
const repo = parseRepo(flags.repo);
|
const repo = parseRepo(flags.repo);
|
||||||
const index = parseIndex(flags.index);
|
const index = parseIndex(flags.index);
|
||||||
const issuePath = `/repos/${repo}/issues/${index}`;
|
const issuePath = `/repos/${repo}/issues/${index}`;
|
||||||
const commentsPath = `${issuePath}/comments`;
|
const timelinePath = `${issuePath}/timeline`;
|
||||||
|
|
||||||
if (flags['dry-run']) {
|
if (flags['dry-run']) {
|
||||||
return {
|
return {
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
repo,
|
repo,
|
||||||
index,
|
|
||||||
requests: [
|
requests: [
|
||||||
{ method: 'GET', path: issuePath },
|
{ method: 'GET', path: issuePath },
|
||||||
{ method: 'GET', path: commentsPath },
|
{ method: 'GET', path: timelinePath },
|
||||||
],
|
],
|
||||||
note: UNMERGED_COMMENT_NOTE,
|
note: UNMERGED_COMMENT_NOTE,
|
||||||
};
|
};
|
||||||
@@ -62,7 +61,7 @@ main(async () => {
|
|||||||
目標: listSection(sections, '目標'),
|
目標: listSection(sections, '目標'),
|
||||||
非目標: listSection(sections, '非目標'),
|
非目標: listSection(sections, '非目標'),
|
||||||
名詞表: tableSection(sections, '領域名詞表'),
|
名詞表: tableSection(sections, '領域名詞表'),
|
||||||
流程圖: textSection(sections, '流程圖'),
|
文件: textSection(sections, '文件'),
|
||||||
驗收標準: listSection(sections, '驗收標準'),
|
驗收標準: listSection(sections, '驗收標準'),
|
||||||
影響範圍: listSection(sections, '影響範圍'),
|
影響範圍: listSection(sections, '影響範圍'),
|
||||||
未決事項: listSection(sections, '未決事項'),
|
未決事項: listSection(sections, '未決事項'),
|
||||||
|
|||||||
+11
-5
@@ -974,16 +974,22 @@ export async function countUnmergedComments(login, repo, index, me) {
|
|||||||
/**
|
/**
|
||||||
* 逐頁走過一顆議題(或 PR)的一般留言。
|
* 逐頁走過一顆議題(或 PR)的一般留言。
|
||||||
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
||||||
|
* Gitea 的 comments 端點固定只回第一批,完整清單改從 timeline 取得。
|
||||||
* @returns {AsyncGenerator<object>} 一則一則交出去
|
* @returns {AsyncGenerator<object>} 一則一則交出去
|
||||||
*/
|
*/
|
||||||
export async function* listIssueComments(login, repo, index) {
|
export async function* listIssueComments(login, repo, index) {
|
||||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
const path = `/repos/${repo}/issues/${index}/timeline`;
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
for await (const comments of pages(login, path, {
|
for await (const entries of pages(login, path, {
|
||||||
limitCode: 'COMMENT_LIMIT',
|
limitCode: 'TIMELINE_LIMIT',
|
||||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
limitHint: `${path} 的 timeline 太多,讀不完整份留言清單`,
|
||||||
})) {
|
})) {
|
||||||
for (const comment of comments) yield comment;
|
for (const entry of entries) {
|
||||||
|
if (entry.type !== 'comment' || seen.has(entry.id)) continue;
|
||||||
|
seen.add(entry.id);
|
||||||
|
yield entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-8
@@ -24,6 +24,7 @@ import {
|
|||||||
ScriptError,
|
ScriptError,
|
||||||
expectOk,
|
expectOk,
|
||||||
giteaRequest,
|
giteaRequest,
|
||||||
|
listIssueComments,
|
||||||
main,
|
main,
|
||||||
pages,
|
pages,
|
||||||
parseFlags,
|
parseFlags,
|
||||||
@@ -205,14 +206,8 @@ async function findInline(login, repo, index, commentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findGeneral(login, repo, index, commentId) {
|
async function findGeneral(login, repo, index, commentId) {
|
||||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
for await (const comment of listIssueComments(login, repo, index)) {
|
||||||
|
if (comment.id === commentId) return comment;
|
||||||
for await (const comments of pages(login, path, {
|
|
||||||
limitCode: 'COMMENT_LIMIT',
|
|
||||||
limitHint: `${path} 的留言太多,找不完`,
|
|
||||||
})) {
|
|
||||||
const hit = comments.find((comment) => comment.id === commentId);
|
|
||||||
if (hit) return hit;
|
|
||||||
}
|
}
|
||||||
throw notFound(index, '一般留言', commentId);
|
throw notFound(index, '一般留言', commentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ export function plannedRequests(repo, index) {
|
|||||||
return [
|
return [
|
||||||
{ method: 'GET', path: '/user' },
|
{ method: 'GET', path: '/user' },
|
||||||
{ method: 'GET', path: pullPath },
|
{ method: 'GET', path: pullPath },
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
|
||||||
{ method: 'GET', path: `${pullPath}/reviews` },
|
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||||
|
{ method: 'GET', path: `${pullPath}/reviews` },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ export function plannedRequests(repo, index) {
|
|||||||
export function commonRequests(repo, index) {
|
export function commonRequests(repo, index) {
|
||||||
return [
|
return [
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
||||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+269
-35
@@ -1,50 +1,46 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* 依相依關係推算每顆工作包的截止日。
|
* 依工作日與相依關係計算工作包的日期、CPM 與 PERT。
|
||||||
*
|
*
|
||||||
* 保證一件事:任一工作包的截止日都不早於它的先決工作包。人工排時程最常出現的
|
* 政府辦公日曆只影響日期,不影響人天估算;取不到某年度資料時仍會跳過週末,
|
||||||
* 矛盾就是前置工作比後續還晚到期,看板上看起來合理、實際上做不到。
|
* 並把警告寫到 stderr。輸出的日期採結束日不含在工期內,延續原本 dueDate 契約。
|
||||||
*
|
|
||||||
* 這支腳本不碰 Gitea 也不碰 git,純算數字,所以沒有前置檢查、也不需要登入。
|
|
||||||
* 算出來的日期交給 issue-update 逐顆寫上去。
|
|
||||||
*
|
|
||||||
* 日期以「日曆日」累加,不跳週末也不扣假日——跳過哪些日子是團隊政策,
|
|
||||||
* 這裡不替使用者決定。
|
|
||||||
*
|
*
|
||||||
* 用法:node scripts/schedule.js --plan-file <計畫檔>
|
* 用法:node scripts/schedule.js --plan-file <計畫檔>
|
||||||
*
|
*
|
||||||
* 計畫檔格式:
|
* 計畫檔格式:
|
||||||
* {
|
* {
|
||||||
* "startDate": "2026-09-21",
|
* "startDate": "2026-09-22",
|
||||||
* "workPackages": [
|
* "workPackages": [
|
||||||
* { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] }
|
* { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] },
|
||||||
|
* {
|
||||||
|
* "index": 13, "title": "驗證", "optimistic": 2,
|
||||||
|
* "mostLikely": 3, "pessimistic": 5, "depends": [12]
|
||||||
|
* }
|
||||||
* ]
|
* ]
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { ScriptError, main, parseFlags } from './lib.js';
|
import { ScriptError, main, parseFlags } from './lib.js';
|
||||||
|
|
||||||
|
const CALENDAR_METADATA_URL = 'https://data.gov.tw/api/front/dataset/detail?nid=14718';
|
||||||
|
const CALENDAR_URL_OVERRIDE = 'TEA_SDLC_CALENDAR_URL';
|
||||||
|
const calendarCache = new Map();
|
||||||
|
|
||||||
main(async () => {
|
main(async () => {
|
||||||
const flags = parseFlags(process.argv.slice(2), { required: ['plan-file'] });
|
const flags = parseFlags(process.argv.slice(2), { required: ['plan-file'] });
|
||||||
const plan = readPlan(flags['plan-file']);
|
const plan = readPlan(flags['plan-file']);
|
||||||
|
|
||||||
const order = topologicalOrder(plan.workPackages);
|
const order = topologicalOrder(plan.workPackages);
|
||||||
const dueByIndex = new Map();
|
const estimates = new Map(plan.workPackages.map((wp) => [wp.index, pertFor(wp)]));
|
||||||
const schedule = [];
|
const holidays = await loadHolidays(plan, order, estimates);
|
||||||
|
const schedule = buildSchedule(plan, order, estimates, holidays);
|
||||||
|
|
||||||
for (const index of order) {
|
return {
|
||||||
const wp = plan.workPackages.find((item) => item.index === index);
|
startDate: plan.startDate,
|
||||||
// 從所有先決裡最晚的那一個接著做;沒有先決就從起始日開始
|
order,
|
||||||
const readyFrom = (wp.depends ?? []).reduce(
|
schedule: schedule.items,
|
||||||
(latest, dep) => (dueByIndex.get(dep) > latest ? dueByIndex.get(dep) : latest),
|
criticalPath: schedule.criticalPath,
|
||||||
plan.startDate,
|
project: schedule.project,
|
||||||
);
|
};
|
||||||
const dueDate = addDays(readyFrom, wp.days);
|
|
||||||
dueByIndex.set(index, dueDate);
|
|
||||||
schedule.push({ index, title: wp.title, days: wp.days, dueDate });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startDate: plan.startDate, order, schedule };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function readPlan(planFile) {
|
function readPlan(planFile) {
|
||||||
@@ -59,7 +55,7 @@ function readPlan(planFile) {
|
|||||||
throw new ScriptError('BAD_PLAN', `${planFile} 不是合法的 JSON:${error.message}`);
|
throw new ScriptError('BAD_PLAN', `${planFile} 不是合法的 JSON:${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(plan.startDate ?? '')) {
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(plan.startDate ?? '') || !validDate(plan.startDate)) {
|
||||||
throw new ScriptError('BAD_PLAN', `startDate 需為 YYYY-MM-DD,收到的是 ${plan.startDate}`);
|
throw new ScriptError('BAD_PLAN', `startDate 需為 YYYY-MM-DD,收到的是 ${plan.startDate}`);
|
||||||
}
|
}
|
||||||
if (!Array.isArray(plan.workPackages) || plan.workPackages.length === 0) {
|
if (!Array.isArray(plan.workPackages) || plan.workPackages.length === 0) {
|
||||||
@@ -68,8 +64,7 @@ function readPlan(planFile) {
|
|||||||
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const wp of plan.workPackages) {
|
for (const wp of plan.workPackages) {
|
||||||
// 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者,
|
// 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者。
|
||||||
// 算出來的時程會是兩份定義混出來的東西,而且完全不會報錯
|
|
||||||
if (seen.has(wp.index)) {
|
if (seen.has(wp.index)) {
|
||||||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`);
|
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`);
|
||||||
}
|
}
|
||||||
@@ -78,16 +73,48 @@ function readPlan(planFile) {
|
|||||||
if (!Number.isInteger(wp.index) || wp.index <= 0) {
|
if (!Number.isInteger(wp.index) || wp.index <= 0) {
|
||||||
throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`);
|
throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`);
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(wp.days) || wp.days <= 0) {
|
if (
|
||||||
|
wp.depends !== undefined &&
|
||||||
|
(!Array.isArray(wp.depends) || wp.depends.some((dep) => !Number.isInteger(dep)))
|
||||||
|
) {
|
||||||
|
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 depends 需為整數陣列`);
|
||||||
|
}
|
||||||
|
const estimate = pertFor(wp);
|
||||||
|
if (estimate === null && (!Number.isFinite(wp.days) || wp.days <= 0)) {
|
||||||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 days 需為正數,收到的是 ${wp.days}`);
|
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 days 需為正數,收到的是 ${wp.days}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pertFor(wp) {
|
||||||
|
const source = wp.pert ?? wp.estimates ?? wp;
|
||||||
|
const names = [
|
||||||
|
['optimistic', 'mostLikely', 'pessimistic'],
|
||||||
|
['optimisticDays', 'mostLikelyDays', 'pessimisticDays'],
|
||||||
|
['o', 'm', 'p'],
|
||||||
|
];
|
||||||
|
const fields = names.find(([o, m, p]) => [o, m, p].some((name) => source[name] !== undefined));
|
||||||
|
if (!fields) return null;
|
||||||
|
|
||||||
|
const [optimistic, mostLikely, pessimistic] = fields.map((name) => Number(source[name]));
|
||||||
|
if (
|
||||||
|
![optimistic, mostLikely, pessimistic].every((value) => Number.isFinite(value) && value > 0) ||
|
||||||
|
optimistic > mostLikely ||
|
||||||
|
mostLikely > pessimistic
|
||||||
|
) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'BAD_PLAN',
|
||||||
|
`工作包 #${wp.index} 的 PERT 估算需為正數且符合 optimistic ≤ mostLikely ≤ pessimistic`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expected = (optimistic + 4 * mostLikely + pessimistic) / 6;
|
||||||
|
const variance = ((pessimistic - optimistic) / 6) ** 2;
|
||||||
|
return { optimistic, mostLikely, pessimistic, expected, variance };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拓撲排序:先決一定排在後續之前。
|
* 拓撲排序:先決一定排在後續之前。排不完就代表有環。
|
||||||
* 用 Kahn 演算法——排不完就代表有環,而環上的成員正是排不進去的那些。
|
|
||||||
*/
|
*/
|
||||||
function topologicalOrder(workPackages) {
|
function topologicalOrder(workPackages) {
|
||||||
const known = new Set(workPackages.map((wp) => wp.index));
|
const known = new Set(workPackages.map((wp) => wp.index));
|
||||||
@@ -130,9 +157,216 @@ function topologicalOrder(workPackages) {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在 YYYY-MM-DD 上加幾個日曆日,回傳同樣格式 */
|
async function loadHolidays(plan, order, estimates) {
|
||||||
function addDays(date, days) {
|
const totalDays = order.reduce((total, index) => {
|
||||||
|
const wp = plan.workPackages.find((item) => item.index === index);
|
||||||
|
return total + Math.ceil(estimates.get(index)?.expected ?? wp.days);
|
||||||
|
}, 0);
|
||||||
|
const lastDate = addCalendarDays(plan.startDate, totalDays + 366);
|
||||||
|
const years = [];
|
||||||
|
for (let year = yearOf(plan.startDate); year <= yearOf(lastDate); year += 1) years.push(year);
|
||||||
|
|
||||||
|
const holidays = new Map();
|
||||||
|
for (const year of years) holidays.set(year, await holidaysForYear(year));
|
||||||
|
return holidays;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function holidaysForYear(year) {
|
||||||
|
if (calendarCache.has(year)) return calendarCache.get(year);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = process.env[CALENDAR_URL_OVERRIDE]?.trim() || (await calendarResourceUrl(year));
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const holidays = parseCalendar(await response.text(), year);
|
||||||
|
calendarCache.set(year, holidays);
|
||||||
|
return holidays;
|
||||||
|
} catch (error) {
|
||||||
|
const fallback = new Set();
|
||||||
|
calendarCache.set(year, fallback);
|
||||||
|
console.error(`警告:無法取得 ${year} 年台灣辦公日曆(${error.message}),改以週末作為非工作日`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calendarResourceUrl(year) {
|
||||||
|
const response = await fetch(CALENDAR_METADATA_URL, { signal: AbortSignal.timeout(10_000) });
|
||||||
|
if (!response.ok) throw new Error(`日曆中繼資料 HTTP ${response.status}`);
|
||||||
|
const body = await response.json();
|
||||||
|
const rocYear = year - 1911;
|
||||||
|
const resources = body.payload?.resources ?? [];
|
||||||
|
const resource = [...resources].reverse().find(
|
||||||
|
(item) =>
|
||||||
|
item.file_format?.toUpperCase() === 'CSV' &&
|
||||||
|
item.description?.startsWith(`${rocYear}年`) &&
|
||||||
|
item.description.includes('政府行政機關辦公日曆表') &&
|
||||||
|
!item.description.includes('Google'),
|
||||||
|
);
|
||||||
|
if (!resource?.url) throw new Error(`資料集沒有 ${year} 年 CSV`);
|
||||||
|
return resource.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCalendar(text, year) {
|
||||||
|
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim() !== '');
|
||||||
|
if (lines.length < 2) throw new Error('日曆 CSV 沒有資料');
|
||||||
|
const header = parseCsvLine(lines[0]).map((field) => field.trim());
|
||||||
|
const dateColumn = header.indexOf('西元日期');
|
||||||
|
const holidayColumn = header.indexOf('是否放假');
|
||||||
|
if (dateColumn < 0 || holidayColumn < 0) throw new Error('日曆 CSV 缺少必要欄位');
|
||||||
|
|
||||||
|
const holidays = new Set();
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const fields = parseCsvLine(line);
|
||||||
|
const date = fields[dateColumn]?.trim();
|
||||||
|
const holiday = fields[holidayColumn]?.trim();
|
||||||
|
if (/^\d{8}$/.test(date) && Number(date.slice(0, 4)) === year && holiday !== '0') {
|
||||||
|
holidays.add(`${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return holidays;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLine(line) {
|
||||||
|
const fields = [];
|
||||||
|
let field = '';
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < line.length; i += 1) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"' && line[i + 1] === '"' && quoted) {
|
||||||
|
field += '"';
|
||||||
|
i += 1;
|
||||||
|
} else if (char === '"') {
|
||||||
|
quoted = !quoted;
|
||||||
|
} else if (char === ',' && !quoted) {
|
||||||
|
fields.push(field);
|
||||||
|
field = '';
|
||||||
|
} else {
|
||||||
|
field += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(field);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSchedule(plan, order, estimates, holidays) {
|
||||||
|
const byIndex = new Map(plan.workPackages.map((wp) => [wp.index, wp]));
|
||||||
|
const early = new Map();
|
||||||
|
for (const index of order) {
|
||||||
|
const wp = byIndex.get(index);
|
||||||
|
const duration = Math.ceil(estimates.get(index)?.expected ?? wp.days);
|
||||||
|
const ES = (wp.depends ?? []).reduce(
|
||||||
|
(latest, dep) => (early.get(dep).EF > latest ? early.get(dep).EF : latest),
|
||||||
|
plan.startDate,
|
||||||
|
);
|
||||||
|
const EF = addWorkdays(ES, duration, holidays);
|
||||||
|
early.set(index, { ES, EF, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectFinish = [...early.values()].reduce(
|
||||||
|
(latest, item) => (item.EF > latest ? item.EF : latest),
|
||||||
|
plan.startDate,
|
||||||
|
);
|
||||||
|
const successors = new Map(order.map((index) => [index, []]));
|
||||||
|
for (const wp of plan.workPackages) {
|
||||||
|
for (const dep of wp.depends ?? []) successors.get(dep).push(wp.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const late = new Map();
|
||||||
|
for (const index of [...order].reverse()) {
|
||||||
|
const { duration } = early.get(index);
|
||||||
|
const successorStarts = successors.get(index).map((successor) => late.get(successor).LS);
|
||||||
|
const LF = successorStarts.length === 0 ? projectFinish : successorStarts.reduce(minDate);
|
||||||
|
const LS = subtractWorkdays(LF, duration, holidays);
|
||||||
|
late.set(index, { LS, LF });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = order.map((index) => {
|
||||||
|
const wp = byIndex.get(index);
|
||||||
|
const estimate = estimates.get(index);
|
||||||
|
const { ES, EF, duration } = early.get(index);
|
||||||
|
const { LS, LF } = late.get(index);
|
||||||
|
const float = workdayDistance(ES, LS, holidays);
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
title: wp.title,
|
||||||
|
days: wp.days,
|
||||||
|
dueDate: EF,
|
||||||
|
ES,
|
||||||
|
EF,
|
||||||
|
LS,
|
||||||
|
LF,
|
||||||
|
float,
|
||||||
|
critical: float === 0,
|
||||||
|
expectedDays: estimate?.expected ?? wp.days,
|
||||||
|
...(estimate ? { pert: estimate } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const criticalPath = items.filter((item) => item.critical).map((item) => item.index);
|
||||||
|
const variance = items.reduce((total, item) => total + (item.pert?.variance ?? 0), 0);
|
||||||
|
const expectedDuration = items.reduce((total, item) => total + (item.pert?.expected ?? item.days), 0);
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
criticalPath,
|
||||||
|
project: {
|
||||||
|
finishDate: projectFinish,
|
||||||
|
expectedDuration,
|
||||||
|
variance,
|
||||||
|
standardDeviation: Math.sqrt(variance),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWorkdays(date, days, holidays) {
|
||||||
|
let current = date;
|
||||||
|
let remaining = days;
|
||||||
|
while (remaining > 0) {
|
||||||
|
current = addCalendarDays(current, 1);
|
||||||
|
if (isWorkday(current, holidays)) remaining -= 1;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractWorkdays(date, days, holidays) {
|
||||||
|
let current = date;
|
||||||
|
let remaining = days;
|
||||||
|
while (remaining > 0) {
|
||||||
|
current = addCalendarDays(current, -1);
|
||||||
|
if (isWorkday(current, holidays)) remaining -= 1;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workdayDistance(start, end, holidays) {
|
||||||
|
if (start === end) return 0;
|
||||||
|
let current = start;
|
||||||
|
let distance = 0;
|
||||||
|
while (current < end) {
|
||||||
|
current = addCalendarDays(current, 1);
|
||||||
|
if (isWorkday(current, holidays)) distance += 1;
|
||||||
|
}
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkday(date, holidays) {
|
||||||
|
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
||||||
|
return day !== 0 && day !== 6 && !holidays.get(yearOf(date))?.has(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function minDate(a, b) {
|
||||||
|
return a < b ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCalendarDays(date, days) {
|
||||||
const moment = new Date(`${date}T00:00:00Z`);
|
const moment = new Date(`${date}T00:00:00Z`);
|
||||||
moment.setUTCDate(moment.getUTCDate() + days);
|
moment.setUTCDate(moment.getUTCDate() + days);
|
||||||
return moment.toISOString().slice(0, 10);
|
return moment.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validDate(date) {
|
||||||
|
const moment = new Date(`${date}T00:00:00Z`);
|
||||||
|
return !Number.isNaN(moment.valueOf()) && moment.toISOString().slice(0, 10) === date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearOf(date) {
|
||||||
|
return Number(date.slice(0, 4));
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ main(async () => {
|
|||||||
{ method: 'GET', path: issuePath },
|
{ method: 'GET', path: issuePath },
|
||||||
{ method: 'GET', path: `${issuePath}/dependencies` },
|
{ method: 'GET', path: `${issuePath}/dependencies` },
|
||||||
{ method: 'GET', path: `${issuePath}/blocks` },
|
{ method: 'GET', path: `${issuePath}/blocks` },
|
||||||
{ method: 'GET', path: `${issuePath}/comments` },
|
{ method: 'GET', path: `${issuePath}/timeline` },
|
||||||
],
|
],
|
||||||
note: UNMERGED_COMMENT_NOTE,
|
note: UNMERGED_COMMENT_NOTE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
{{名詞表}}
|
{{名詞表}}
|
||||||
|
|
||||||
## 流程圖
|
## 文件
|
||||||
|
|
||||||
{{流程圖}}
|
{{文件}}
|
||||||
|
|
||||||
## 驗收標準
|
## 驗收標準
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ test('打包內容以白名單決定:四個正本目錄都在,測試與暫
|
|||||||
const packed = JSON.parse(
|
const packed = JSON.parse(
|
||||||
execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: repoRoot, encoding: 'utf8' }),
|
execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: repoRoot, encoding: 'utf8' }),
|
||||||
);
|
);
|
||||||
const files = packed[0].files.map((file) => file.path);
|
const manifest = Array.isArray(packed) ? packed[0] : packed[Object.keys(packed)[0]];
|
||||||
|
const files = manifest.files.map((file) => file.path);
|
||||||
|
|
||||||
for (const dir of ['prompts/', 'scripts/', 'templates/', 'references/', 'bin/']) {
|
for (const dir of ['prompts/', 'scripts/', 'templates/', 'references/', 'bin/']) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` },
|
body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` },
|
||||||
}),
|
}),
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: comments.map((id) => ({ id, body: `留言 ${id}`, user: { login: 'someone' } })),
|
body: comments.map((id) => ({ id, type: 'comment', body: `留言 ${id}`, user: { login: 'someone' } })),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
for (const id of comments) {
|
for (const id of comments) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readReference, readPrompt, assertNeutralPrompt } from './helpers/prompt-doc.js';
|
||||||
|
|
||||||
|
const RULES = readReference('delegation');
|
||||||
|
const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report'];
|
||||||
|
const EXPECTED = [
|
||||||
|
['sdlc-plan', '列出九段落依據與缺漏'],
|
||||||
|
['sdlc-analyze', '對四份清單列出疑點'],
|
||||||
|
['sdlc-analyze', '算出截止日'],
|
||||||
|
['sdlc-feat', '把議題標題翻成英文'],
|
||||||
|
['sdlc-feat', '分批提交方案'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function tableEntries() {
|
||||||
|
return [...RULES.matchAll(/^\| `([^`]+)` \| ([^|]+) \|/gm)]
|
||||||
|
.map((match) => [match[1], match[2].trim()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptMarkers(name) {
|
||||||
|
const prompt = readPrompt(name);
|
||||||
|
return [...prompt.matchAll(/^### (?:\d+\.\s+)?(.+?)〔可委派〕\s*$/gm)]
|
||||||
|
.map((match) => [name, match[1].trim()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 四條判準與雙向集合 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('委派規則保留四條判準與兩條硬排除', () => {
|
||||||
|
for (const term of ['可驗證的成品', '不會詢問使用者', '失敗能被呼叫端偵測', '不直接寫入 Gitea 或 git']) {
|
||||||
|
assert.match(RULES, new RegExp(term));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('委派表與六份正本的標記集合完全一致', () => {
|
||||||
|
const table = tableEntries();
|
||||||
|
const markers = PROMPTS.flatMap(promptMarkers);
|
||||||
|
assert.deepEqual(table, EXPECTED);
|
||||||
|
assert.deepEqual(markers, EXPECTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每個標記步驟都說明能力降級與部分委派邊界', () => {
|
||||||
|
for (const [name, step] of EXPECTED) {
|
||||||
|
const prompt = readPrompt(name);
|
||||||
|
const marker = prompt.match(new RegExp(`^### (?:\\d+\\.\\s+)?${step}〔可委派〕\\s*$`, 'm'));
|
||||||
|
assert.ok(marker, `${name} 缺少標記:${step}`);
|
||||||
|
const start = marker.index;
|
||||||
|
const body = prompt.slice(start, prompt.indexOf('\n### ', start + 1) === -1 ? prompt.length : prompt.indexOf('\n### ', start + 1));
|
||||||
|
assert.match(body, /你的環境若能把工作交給子代理|實際執行.*不委派/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 六份正本的平台與文字契約 ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('六份流程正本均為平台中立且使用正確 description 前綴', () => {
|
||||||
|
for (const name of PROMPTS) assertNeutralPrompt(readPrompt(name), name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('沒有委派標記的流程仍明確列入規則表的空集合', () => {
|
||||||
|
for (const name of ['sdlc-fix', 'sdlc-sync', 'sdlc-report']) {
|
||||||
|
assert.equal(promptMarkers(name).length, 0, `${name} 不應偷偷出現委派步驟`);
|
||||||
|
}
|
||||||
|
assert.match(RULES, /sdlc-sync.*sdlc-fix.*sdlc-report/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readReference, readTemplate } from './helpers/prompt-doc.js';
|
||||||
|
|
||||||
|
const rules = readReference('delivery-types');
|
||||||
|
const template = readTemplate('requirement-issue');
|
||||||
|
const TYPES = [
|
||||||
|
'需求描述概要',
|
||||||
|
'WBS(工作分解結構)',
|
||||||
|
'流程圖',
|
||||||
|
'甘特圖',
|
||||||
|
'PERT 圖',
|
||||||
|
'關鍵路徑圖',
|
||||||
|
'API 契約文件',
|
||||||
|
];
|
||||||
|
|
||||||
|
function sectionOf(text, heading) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const start = lines.findIndex((line) => line.replace(/^### (?:\d+\. )?/, '') === heading);
|
||||||
|
assert.notEqual(start, -1, `規則正本缺少「${heading}」`);
|
||||||
|
const rest = lines.slice(start + 1);
|
||||||
|
const end = rest.findIndex((line) => line.startsWith('### '));
|
||||||
|
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('需求模板以文件取代流程圖,且保留固定段落順序', () => {
|
||||||
|
const headings = [...template.matchAll(/^## (.+)$/gm)].map((match) => match[1]);
|
||||||
|
assert.deepEqual(headings, [
|
||||||
|
'總覽', '背景', '目標', '非目標', '領域名詞表', '文件', '驗收標準', '影響範圍', '未決事項',
|
||||||
|
]);
|
||||||
|
assert.match(template, /\{\{文件\}\}/);
|
||||||
|
assert.equal(template.includes('## 流程圖'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('規則正本逐一交代七種文件的必要內容、產出位置與 ELI5 變體', () => {
|
||||||
|
assert.match(rules, /^## 七種交付類型$/m);
|
||||||
|
for (const type of TYPES) {
|
||||||
|
const section = sectionOf(rules, type);
|
||||||
|
assert.match(section, /必要內容/);
|
||||||
|
assert.match(section, /產出位置/);
|
||||||
|
assert.match(section, /ELI5 變體/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API 契約規則禁止寫入目標專案,且摘要可由來源重產', () => {
|
||||||
|
const section = sectionOf(rules, 'API 契約文件');
|
||||||
|
assert.match(section, /禁止把 API 契約文件寫入目標專案 repo/);
|
||||||
|
assert.match(rules, /摘要可由同一份來源重新產生/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('交付文件共通規則保留逐一確認與預覽能力分流', () => {
|
||||||
|
assert.match(rules, /逐一確認/);
|
||||||
|
assert.match(rules, /可開啟、可分享的預覽/);
|
||||||
|
assert.match(rules, /沒有預覽能力時依使用者確認的方式交付/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { repoRoot } from './helpers/run-script.js';
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
...['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report']
|
||||||
|
.map((name) => join(repoRoot, 'prompts', `${name}.md`)),
|
||||||
|
join(repoRoot, 'references', 'delegation.md'),
|
||||||
|
join(repoRoot, 'AGENTS.md'),
|
||||||
|
join(repoRoot, 'README.md'),
|
||||||
|
join(repoRoot, 'docs', 'adr', '0001-以集中式雜湊路徑的-worktree-隔離平行工作包.md'),
|
||||||
|
join(repoRoot, 'docs', 'adr', '0002-以能力描述而非工具名表達委派.md'),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('流程、規則與文件資產都是合法 UTF-8 且沒有替代字元', () => {
|
||||||
|
for (const path of files) {
|
||||||
|
const bytes = readFileSync(path);
|
||||||
|
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||||
|
assert.equal(text.includes('\uFFFD'), false, `${path} 含替代字元`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('六份流程正本都以繁體中文 description 開頭', () => {
|
||||||
|
for (const path of files.slice(0, 6)) {
|
||||||
|
const text = readFileSync(path, 'utf8');
|
||||||
|
assert.match(text, /^description: 僅由 \/sdlc-[a-z-]+ 指令叫用。/m, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { manifest, tmpRoot } from './helpers/run-script.js';
|
import { manifest, pathWithOnly, tmpRoot } from './helpers/run-script.js';
|
||||||
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||||
import { startStubGitea } from './helpers/stub-gitea.js';
|
import { startStubGitea } from './helpers/stub-gitea.js';
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ test('PATH 上找不到 tea-sdlc 時整體 ok:false,並指出病灶在 PATH
|
|||||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||||
const home = makeHome(t);
|
const home = makeHome(t);
|
||||||
|
|
||||||
const { code, json } = await inHome(plugin, home)(['install'], { shim: false });
|
const { code, json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) });
|
||||||
|
|
||||||
assert.equal(code, 1);
|
assert.equal(code, 1);
|
||||||
assert.equal(json.ok, false);
|
assert.equal(json.ok, false);
|
||||||
@@ -261,7 +261,7 @@ test('驗證失敗時已經寫好的轉接檔一份都不刪', async (t) => {
|
|||||||
const home = makeHome(t);
|
const home = makeHome(t);
|
||||||
|
|
||||||
// 病灶在 PATH,不在轉接檔:刪掉轉接檔只會讓使用者從「有點舊但能用」變成什麼都沒有
|
// 病灶在 PATH,不在轉接檔:刪掉轉接檔只會讓使用者從「有點舊但能用」變成什麼都沒有
|
||||||
const { json } = await inHome(plugin, home)(['install'], { shim: false });
|
const { json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) });
|
||||||
|
|
||||||
assert.equal(json.ok, false);
|
assert.equal(json.ok, false);
|
||||||
for (const path of adaptersOf(json)) {
|
for (const path of adaptersOf(json)) {
|
||||||
|
|||||||
@@ -176,6 +176,27 @@ test('支援 command 的四個平台產生 command 轉接檔,另外三個產
|
|||||||
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
|
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('七個平台的轉接檔都保留繁體中文且沒有 UTF-8 亂碼', async (t) => {
|
||||||
|
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
||||||
|
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
|
||||||
|
|
||||||
|
await inHome(plugin, home)(['install']);
|
||||||
|
const files = [
|
||||||
|
join(home, '.claude', 'commands', 'sdlc-plan.md'),
|
||||||
|
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
|
||||||
|
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
|
||||||
|
join(home, '.omp', 'agent', 'commands', 'sdlc-plan.md'),
|
||||||
|
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||||
|
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||||
|
join(home, 'work', '.github', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of files) {
|
||||||
|
const text = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path));
|
||||||
|
assert.match(text, /僅由 \/sdlc-plan 指令叫用。/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('轉接檔內容是一句指向 tea-sdlc 的話,不含任何檔案路徑', async (t) => {
|
test('轉接檔內容是一句指向 tea-sdlc 的話,不含任何檔案路徑', async (t) => {
|
||||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS, version: '1.2.3' });
|
const plugin = makeFakePlugin(t, { prompts: PROMPTS, version: '1.2.3' });
|
||||||
const home = makeHome(t, ['claude']);
|
const home = makeHome(t, ['claude']);
|
||||||
|
|||||||
+39
-22
@@ -40,7 +40,7 @@ const FULL_BODY = `## 總覽
|
|||||||
| 需求議題 | 描述一項需求的 Gitea issue |
|
| 需求議題 | 描述一項需求的 Gitea issue |
|
||||||
| 工作包 | 從需求拆出的可獨立完成的單位 |
|
| 工作包 | 從需求拆出的可獨立完成的單位 |
|
||||||
|
|
||||||
## 流程圖
|
## 文件
|
||||||
|
|
||||||
\`\`\`mermaid
|
\`\`\`mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -75,9 +75,9 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
|
|||||||
labels: [{ id: 55, name: 'ready-for-agent' }],
|
labels: [{ id: 55, name: 'ready-for-agent' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: comments.map((c, i) => ({ id: 100 + i, body: c.body })),
|
body: comments.map((c, i) => ({ id: 100 + i, type: 'comment', body: c.body })),
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -108,7 +108,7 @@ test('抽出契約上的每一個欄位', async (t) => {
|
|||||||
assert.equal(code, 0);
|
assert.equal(code, 0);
|
||||||
assert.deepEqual(Object.keys(json.data).sort(), [
|
assert.deepEqual(Object.keys(json.data).sort(), [
|
||||||
'index', 'labels', 'title', 'url',
|
'index', 'labels', 'title', 'url',
|
||||||
'影響範圍', '未決事項', '未處理留言數', '流程圖',
|
'影響範圍', '未決事項', '未處理留言數', '文件',
|
||||||
'目標', '總覽', '背景', '名詞表', '非目標', '驗收標準',
|
'目標', '總覽', '背景', '名詞表', '非目標', '驗收標準',
|
||||||
].sort());
|
].sort());
|
||||||
});
|
});
|
||||||
@@ -159,14 +159,14 @@ test('名詞表解析成 term 與 def,表頭與分隔列不算一筆', async (
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('流程圖原樣帶出,連圍欄一起', async (t) => {
|
test('文件原樣帶出,連圍欄一起', async (t) => {
|
||||||
const stub = await withStub(t);
|
const stub = await withStub(t);
|
||||||
|
|
||||||
const { json } = await run([], stub);
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
assert.match(json.data.流程圖, /^```mermaid/);
|
assert.match(json.data.文件, /^```mermaid/);
|
||||||
assert.match(json.data.流程圖, /flowchart TD/);
|
assert.match(json.data.文件, /flowchart TD/);
|
||||||
assert.match(json.data.流程圖, /```$/);
|
assert.match(json.data.文件, /```$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 模板變體 ───────────────────────────────────────────────────────
|
// ── 模板變體 ───────────────────────────────────────────────────────
|
||||||
@@ -192,7 +192,7 @@ test('缺段落回傳空值而不是報錯', async (t) => {
|
|||||||
assert.equal(code, 0);
|
assert.equal(code, 0);
|
||||||
assert.equal(json.data.總覽, '只有總覽的議題。');
|
assert.equal(json.data.總覽, '只有總覽的議題。');
|
||||||
assert.equal(json.data.背景, '');
|
assert.equal(json.data.背景, '');
|
||||||
assert.equal(json.data.流程圖, '');
|
assert.equal(json.data.文件, '');
|
||||||
assert.deepEqual(json.data.目標, []);
|
assert.deepEqual(json.data.目標, []);
|
||||||
assert.deepEqual(json.data.名詞表, []);
|
assert.deepEqual(json.data.名詞表, []);
|
||||||
assert.deepEqual(json.data.驗收標準, []);
|
assert.deepEqual(json.data.驗收標準, []);
|
||||||
@@ -340,7 +340,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
|||||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||||
[
|
[
|
||||||
`GET /repos/${REPO}/issues/${INDEX}`,
|
`GET /repos/${REPO}/issues/${INDEX}`,
|
||||||
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -354,13 +354,13 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
|||||||
// ── 圍欄與表格的邊界(皆為 code review 抓出的實際缺陷,這裡封住回頭路)────
|
// ── 圍欄與表格的邊界(皆為 code review 抓出的實際缺陷,這裡封住回頭路)────
|
||||||
|
|
||||||
test('~~~ 圍欄裡的井字號不是標題', async (t) => {
|
test('~~~ 圍欄裡的井字號不是標題', async (t) => {
|
||||||
const body = '## 流程圖\n\n~~~\n## 這不是標題\n~~~\n\n## 目標\n\n- 真的目標\n';
|
const body = '## 文件\n\n~~~\n## 這不是標題\n~~~\n\n## 目標\n\n- 真的目標\n';
|
||||||
const stub = await withStub(t, {}, { body });
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
const { json } = await run([], stub);
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
assert.deepEqual(json.data.目標, ['真的目標']);
|
assert.deepEqual(json.data.目標, ['真的目標']);
|
||||||
assert.match(json.data.流程圖, /這不是標題/, '圍欄內容原樣留在流程圖段落裡');
|
assert.match(json.data.文件, /這不是標題/, '圍欄內容原樣留在文件段落裡');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('圍欄裡的減號不是清單項', async (t) => {
|
test('圍欄裡的減號不是清單項', async (t) => {
|
||||||
@@ -382,14 +382,14 @@ test('圍欄要同種標記才算關閉,混用時不會提早收尾', async (t
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('圍欄沒關就到結尾時,其後內容算在圍欄內(與 markdown 渲染一致)', async (t) => {
|
test('圍欄沒關就到結尾時,其後內容算在圍欄內(與 markdown 渲染一致)', async (t) => {
|
||||||
const body = '## 流程圖\n\n```mermaid\nflowchart TD\n\n## 驗收標準\n\n- 被圍欄吃掉\n';
|
const body = '## 文件\n\n```mermaid\nflowchart TD\n\n## 驗收標準\n\n- 被圍欄吃掉\n';
|
||||||
const stub = await withStub(t, {}, { body });
|
const stub = await withStub(t, {}, { body });
|
||||||
|
|
||||||
const { code, json } = await run([], stub);
|
const { code, json } = await run([], stub);
|
||||||
|
|
||||||
assert.equal(code, 0, '不該炸掉,只是內容歸屬不同');
|
assert.equal(code, 0, '不該炸掉,只是內容歸屬不同');
|
||||||
assert.deepEqual(json.data.驗收標準, []);
|
assert.deepEqual(json.data.驗收標準, []);
|
||||||
assert.match(json.data.流程圖, /驗收標準/);
|
assert.match(json.data.文件, /驗收標準/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('名詞表的欄位內容可以有逸脫的直線', async (t) => {
|
test('名詞表的欄位內容可以有逸脫的直線', async (t) => {
|
||||||
@@ -422,17 +422,20 @@ test('巢狀清單一律攤平,不無聲吃掉內容', async (t) => {
|
|||||||
assert.deepEqual(json.data.驗收標準, ['上層項目', '巢狀項目']);
|
assert.deepEqual(json.data.驗收標準, ['上層項目', '巢狀項目']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
test('留言 timeline 逐頁讀完並去除重複事件', async (t) => {
|
||||||
// 第一頁滿 50 筆就得再翻一頁;第二頁不滿才收手
|
// 第一頁滿 50 筆就得再翻一頁;第二頁包含重複事件,仍只算一次
|
||||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i }));
|
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i, type: 'comment' }));
|
||||||
const page2 = Array.from({ length: 20 }, (_, i) => ({ id: 300 + i }));
|
const page2 = [
|
||||||
|
page1.at(-1),
|
||||||
|
...Array.from({ length: 20 }, (_, i) => ({ id: 300 + i, type: 'comment' })),
|
||||||
|
];
|
||||||
const reactions = {};
|
const reactions = {};
|
||||||
for (const { id } of [...page1, ...page2]) {
|
for (const { id } of [...page1, ...page2]) {
|
||||||
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] };
|
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stub = await withStub(t, {
|
const stub = await withStub(t, {
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({
|
||||||
status: 200,
|
status: 200,
|
||||||
body: req.query.page === '1' ? page1 : page2,
|
body: req.query.page === '1' ? page1 : page2,
|
||||||
}),
|
}),
|
||||||
@@ -441,7 +444,21 @@ test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
|||||||
|
|
||||||
const { json } = await run([], stub);
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
const commentPages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/comments`));
|
const timelinePages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/timeline`));
|
||||||
assert.deepEqual(commentPages.map((r) => r.query.page), ['1', '2']);
|
assert.deepEqual(timelinePages.map((r) => r.query.page), ['1', '2']);
|
||||||
assert.equal(json.data.未處理留言數, 70, '第二頁的留言也要算進來');
|
assert.equal(json.data.未處理留言數, 70, '重複事件不應重複計入');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeline 讀取失敗時回傳可區分的錯誤', async (t) => {
|
||||||
|
const stub = await withStub(t, {
|
||||||
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
|
status: 502,
|
||||||
|
body: { message: 'upstream unavailable' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { json } = await run([], stub);
|
||||||
|
|
||||||
|
assert.equal(json.error.code, 'HTTP_ERROR');
|
||||||
|
assert.match(json.error.message, /502/);
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-21
@@ -33,13 +33,23 @@ function routes(overrides = {}, options = {}) {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) },
|
body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) },
|
||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: general.map((c, i) => ({
|
body: [
|
||||||
id: 100 + i,
|
...general.map((c, i) => ({
|
||||||
body: c.body,
|
id: 100 + i,
|
||||||
user: { login: c.user ?? 'reviewer' },
|
type: 'comment',
|
||||||
})),
|
body: c.body,
|
||||||
|
user: { login: c.user ?? 'reviewer' },
|
||||||
|
})),
|
||||||
|
...reviews.map((r, i) => ({
|
||||||
|
id: 400 + i,
|
||||||
|
type: 'review',
|
||||||
|
review_id: 200 + i,
|
||||||
|
body: r.body ?? '',
|
||||||
|
user: { login: r.user ?? 'reviewer' },
|
||||||
|
})),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -50,17 +60,6 @@ function routes(overrides = {}, options = {}) {
|
|||||||
user: { login: r.user ?? 'reviewer' },
|
user: { login: r.user ?? 'reviewer' },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
// 總評在 issue comment 表裡也有一份,timeline 給得出它的 id 與 review_id
|
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
|
||||||
status: 200,
|
|
||||||
body: reviews.map((r, i) => ({
|
|
||||||
id: 400 + i,
|
|
||||||
type: 'review',
|
|
||||||
review_id: 200 + i,
|
|
||||||
body: r.body ?? '',
|
|
||||||
user: { login: r.user ?? 'reviewer' },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
general.forEach((c, i) => {
|
general.forEach((c, i) => {
|
||||||
@@ -416,7 +415,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
|||||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||||
[
|
[
|
||||||
`GET /repos/${REPO}/issues/${INDEX}`,
|
`GET /repos/${REPO}/issues/${INDEX}`,
|
||||||
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.match(json.data.note, /reaction|review/);
|
assert.match(json.data.note, /reaction|review/);
|
||||||
@@ -426,15 +425,20 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
|||||||
// ── 分頁 ───────────────────────────────────────────────────────────
|
// ── 分頁 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
||||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 500 + i, body: `第 ${i} 則`, user: { login: 'r' } }));
|
const page1 = Array.from({ length: 50 }, (_, i) => ({
|
||||||
const page2 = [{ id: 999, body: '最後一則', user: { login: 'r' } }];
|
id: 500 + i,
|
||||||
|
type: 'comment',
|
||||||
|
body: `第 ${i} 則`,
|
||||||
|
user: { login: 'r' },
|
||||||
|
}));
|
||||||
|
const page2 = [{ id: 999, type: 'comment', body: '最後一則', user: { login: 'r' } }];
|
||||||
const reactions = {};
|
const reactions = {};
|
||||||
for (const c of [...page1, ...page2]) {
|
for (const c of [...page1, ...page2]) {
|
||||||
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] };
|
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const stub = await withStub(t, {
|
const stub = await withStub(t, {
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({
|
||||||
status: 200,
|
status: 200,
|
||||||
body: req.query.page === '1' ? page1 : page2,
|
body: req.query.page === '1' ? page1 : page2,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -43,13 +43,12 @@ function routes(overrides = {}) {
|
|||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: [{ id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body }],
|
body: [
|
||||||
|
{ id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body },
|
||||||
|
{ id: GENERAL.id, type: 'comment', body: GENERAL.body, user: { login: 'r' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
[`POST /api/v1/repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`]: { status: 201, body: {} },
|
[`POST /api/v1/repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`]: { status: 201, body: {} },
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
|
||||||
status: 200,
|
|
||||||
body: [{ id: GENERAL.id, body: GENERAL.body, user: { login: 'r' } }],
|
|
||||||
},
|
|
||||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { status: 201, body: { id: 900 } },
|
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { status: 201, body: { id: 900 } },
|
||||||
[`POST /api/v1/repos/${REPO}/issues/comments/${GENERAL.id}/reactions`]: { status: 201, body: {} },
|
[`POST /api/v1/repos/${REPO}/issues/comments/${GENERAL.id}/reactions`]: { status: 201, body: {} },
|
||||||
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } },
|
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } },
|
||||||
|
|||||||
@@ -44,12 +44,16 @@ function routes({ state = 'open', merged = false, draft = false, general = [], h
|
|||||||
head: head ?? { ref: BRANCH, label: BRANCH },
|
head: head ?? { ref: BRANCH, label: BRANCH },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: general.map((c, i) => ({ id: 100 + i, body: c.body, user: { login: 'reviewer' } })),
|
body: general.map((c, i) => ({
|
||||||
|
id: 100 + i,
|
||||||
|
type: 'comment',
|
||||||
|
body: c.body,
|
||||||
|
user: { login: 'reviewer' },
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: [] },
|
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: [] },
|
||||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] },
|
|
||||||
});
|
});
|
||||||
general.forEach((c, i) => {
|
general.forEach((c, i) => {
|
||||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
||||||
|
|||||||
+95
-30
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。
|
* 排程計算:日期只落在工作日,並從同一份計畫產出 PERT 與 CPM。
|
||||||
* 這支腳本不碰 Gitea,純算數字,所以測試只餵檔案、只看 stdout。
|
* 腳本對外會抓政府日曆;測試用 data URL 固定資料,fallback 測試才改用失效網址。
|
||||||
*/
|
*/
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -8,6 +8,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||||
|
|
||||||
|
/** 測試用的 2026 年日曆:12 月 25 日是已知假日,其他日期只由週末判斷。 */
|
||||||
|
const CALENDAR = `data:text/csv,${encodeURIComponent(
|
||||||
|
'西元日期,星期,是否放假,備註\n20261224,四,0,\n20261225,五,2,測試假日\n20261228,一,0,',
|
||||||
|
)}`;
|
||||||
|
|
||||||
/** 把一份排程計畫寫成暫存檔,回傳路徑 */
|
/** 把一份排程計畫寫成暫存檔,回傳路徑 */
|
||||||
function writePlan(plan) {
|
function writePlan(plan) {
|
||||||
mkdirSync(tmpRoot, { recursive: true });
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
@@ -17,14 +22,17 @@ function writePlan(plan) {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = (plan, args = []) =>
|
const run = (plan, args = [], opts = {}) =>
|
||||||
runScript('schedule.js', ['--plan-file', writePlan(plan), ...args]);
|
runScript('schedule.js', ['--plan-file', writePlan(plan), ...args], {
|
||||||
|
...opts,
|
||||||
|
env: { TEA_SDLC_CALENDAR_URL: CALENDAR, ...opts.env },
|
||||||
|
});
|
||||||
|
|
||||||
/** 依 index 取出算出來的截止日 */
|
/** 依 index 取出算出來的截止日 */
|
||||||
const dueOf = (json) =>
|
const dueOf = (json) =>
|
||||||
Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate]));
|
Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate]));
|
||||||
|
|
||||||
test('沒有相依時,每顆各自從起始日加上自己的人天', async () => {
|
test('沒有相依時,每顆各自從起始日加上工作日人天', async () => {
|
||||||
const { code, json } = await run({
|
const { code, json } = await run({
|
||||||
startDate: '2026-09-21',
|
startDate: '2026-09-21',
|
||||||
workPackages: [
|
workPackages: [
|
||||||
@@ -34,10 +42,10 @@ test('沒有相依時,每顆各自從起始日加上自己的人天', async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(code, 0);
|
assert.equal(code, 0);
|
||||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
|
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('有先決時,截止日從先決的截止日往後算', async () => {
|
test('有先決時,截止日從先決的工作日截止日往後算', async () => {
|
||||||
const { json } = await run({
|
const { json } = await run({
|
||||||
startDate: '2026-09-21',
|
startDate: '2026-09-21',
|
||||||
workPackages: [
|
workPackages: [
|
||||||
@@ -46,7 +54,7 @@ test('有先決時,截止日從先決的截止日往後算', async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
|
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('多個先決時取最晚的那一個當起點', async () => {
|
test('多個先決時取最晚的那一個當起點', async () => {
|
||||||
@@ -59,28 +67,31 @@ test('多個先決時取最晚的那一個當起點', async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(dueOf(json)[3], '2026-10-01', '要等最晚的乙做完才開始');
|
assert.equal(dueOf(json)[3], '2026-10-05', '要等最晚的乙做完才開始');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('任一工作包的截止日都不早於它的先決', async () => {
|
test('週末與年度假日都不會被算作工作日', async () => {
|
||||||
const { json } = await run({
|
const { json } = await run({
|
||||||
startDate: '2026-09-21',
|
startDate: '2026-12-24',
|
||||||
workPackages: [
|
workPackages: [{ index: 1, title: '跨假日', days: 2 }],
|
||||||
{ index: 1, title: '甲', days: 3 },
|
|
||||||
{ index: 2, title: '乙', days: 1, depends: [1] },
|
|
||||||
{ index: 3, title: '丙', days: 1, depends: [2] },
|
|
||||||
{ index: 4, title: '丁', days: 4, depends: [1] },
|
|
||||||
{ index: 5, title: '戊', days: 2, depends: [3, 4] },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const due = dueOf(json);
|
assert.equal(dueOf(json)[1], '2026-12-29');
|
||||||
const depends = { 2: [1], 3: [2], 4: [1], 5: [3, 4] };
|
});
|
||||||
for (const [index, prerequisites] of Object.entries(depends)) {
|
|
||||||
for (const p of prerequisites) {
|
test('日曆抓不到時只跳過週末並印出警告', async () => {
|
||||||
assert.ok(due[index] > due[p], `#${index} 的截止日不該早於先決 #${p}`);
|
const { code, json, stderr } = await run(
|
||||||
}
|
{
|
||||||
}
|
startDate: '2026-12-24',
|
||||||
|
workPackages: [{ index: 1, title: 'fallback', days: 2 }],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
{ env: { TEA_SDLC_CALENDAR_URL: 'http://127.0.0.1:1/unavailable.csv' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.equal(dueOf(json)[1], '2026-12-28');
|
||||||
|
assert.match(stderr, /警告.*週末/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => {
|
test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => {
|
||||||
@@ -96,6 +107,54 @@ test('輸出的順序即拓撲順序,先決一定排在後續之前', async ()
|
|||||||
assert.deepEqual(json.data.order, [1, 2, 3]);
|
assert.deepEqual(json.data.order, [1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('CPM 會計算 ES、EF、LS、LF、浮時與關鍵路徑', async () => {
|
||||||
|
const { json } = await run({
|
||||||
|
startDate: '2026-09-21',
|
||||||
|
workPackages: [
|
||||||
|
{ index: 1, title: '長支線', days: 2 },
|
||||||
|
{ index: 2, title: '短支線', days: 1 },
|
||||||
|
{ index: 3, title: '匯合', days: 1, depends: [1, 2] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const byIndex = Object.fromEntries(json.data.schedule.map((item) => [item.index, item]));
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ES: byIndex[2].ES, EF: byIndex[2].EF, LS: byIndex[2].LS, LF: byIndex[2].LF, float: byIndex[2].float },
|
||||||
|
{ ES: '2026-09-21', EF: '2026-09-22', LS: '2026-09-22', LF: '2026-09-23', float: 1 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(json.data.criticalPath, [1, 3]);
|
||||||
|
assert.equal(byIndex[1].critical, true);
|
||||||
|
assert.equal(byIndex[2].critical, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PERT 會計算工作包與專案的期望值、變異數和標準差', async () => {
|
||||||
|
const { json } = await run({
|
||||||
|
startDate: '2026-09-21',
|
||||||
|
workPackages: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
title: '三點估算',
|
||||||
|
optimistic: 2,
|
||||||
|
mostLikely: 3,
|
||||||
|
pessimistic: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = json.data.schedule[0];
|
||||||
|
assert.deepEqual(item.pert, {
|
||||||
|
optimistic: 2,
|
||||||
|
mostLikely: 3,
|
||||||
|
pessimistic: 4,
|
||||||
|
expected: 3,
|
||||||
|
variance: 1 / 9,
|
||||||
|
});
|
||||||
|
assert.equal(item.expectedDays, 3);
|
||||||
|
assert.equal(json.data.project.expectedDuration, 3);
|
||||||
|
assert.equal(json.data.project.variance, 1 / 9);
|
||||||
|
assert.equal(json.data.project.standardDeviation, 1 / 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('相依成環時中止,並指出環上的成員', async () => {
|
test('相依成環時中止,並指出環上的成員', async () => {
|
||||||
const { code, json } = await run({
|
const { code, json } = await run({
|
||||||
startDate: '2026-09-21',
|
startDate: '2026-09-21',
|
||||||
@@ -161,7 +220,7 @@ test('計畫檔不是合法 JSON 時中止', async () => {
|
|||||||
assert.equal(json.error.code, 'BAD_PLAN');
|
assert.equal(json.error.code, 'BAD_PLAN');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async () => {
|
test('輸出保留標題、人天與日期欄位', async () => {
|
||||||
const { json } = await run({
|
const { json } = await run({
|
||||||
startDate: '2026-09-21',
|
startDate: '2026-09-21',
|
||||||
workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }],
|
workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }],
|
||||||
@@ -172,13 +231,20 @@ test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async
|
|||||||
title: '建立抽取契約',
|
title: '建立抽取契約',
|
||||||
days: 3,
|
days: 3,
|
||||||
dueDate: '2026-09-24',
|
dueDate: '2026-09-24',
|
||||||
|
ES: '2026-09-21',
|
||||||
|
EF: '2026-09-24',
|
||||||
|
LS: '2026-09-21',
|
||||||
|
LF: '2026-09-24',
|
||||||
|
float: 0,
|
||||||
|
critical: true,
|
||||||
|
expectedDays: 3,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => {
|
test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => {
|
||||||
const { code } = await runScript(
|
const { code } = await run(
|
||||||
'schedule.js',
|
{ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] },
|
||||||
['--plan-file', writePlan({ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] })],
|
[],
|
||||||
{ env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } },
|
{ env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -186,7 +252,6 @@ test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => {
|
test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => {
|
||||||
// 混出來的結果會是:相依看後者、標題與人天取前者,而且完全不報錯
|
|
||||||
const { code, json } = await run({
|
const { code, json } = await run({
|
||||||
startDate: '2026-09-21',
|
startDate: '2026-09-21',
|
||||||
workPackages: [
|
workPackages: [
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* /sdlc-analyze 的流程正本。
|
||||||
|
*
|
||||||
|
* 這些測試鎖定交付文件確認與排程前置條件;漏掉其中任一個閘門,
|
||||||
|
* agent 就可能在使用者尚未確認時建立工作包或排程。
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assertNeutralPrompt,
|
||||||
|
assertPromptListsSections,
|
||||||
|
readPrompt,
|
||||||
|
} from './helpers/prompt-doc.js';
|
||||||
|
|
||||||
|
const prompt = readPrompt('sdlc-analyze');
|
||||||
|
const delivery = prompt.slice(prompt.indexOf('### 交付文件判斷'), prompt.indexOf('## 第二段'));
|
||||||
|
const packages = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
|
||||||
|
const schedule = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 邊界'));
|
||||||
|
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||||
|
const TYPES = [
|
||||||
|
'需求描述概要',
|
||||||
|
'WBS(工作分解結構)',
|
||||||
|
'流程圖',
|
||||||
|
'甘特圖',
|
||||||
|
'PERT 圖',
|
||||||
|
'關鍵路徑圖',
|
||||||
|
'API 契約文件',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 交付文件確認 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('正本讀取七種交付文件規則,且依規則順序逐項確認', () => {
|
||||||
|
assert.match(delivery, /references\/delivery-types\.md/);
|
||||||
|
assertPromptListsSections(delivery, TYPES);
|
||||||
|
assert.match(delivery, /依序逐一詢問/);
|
||||||
|
assert.match(delivery, /不得把七種文件合併成一次確認/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每種文件確認都保留必要內容、產出位置與 ELI5 規則', () => {
|
||||||
|
assert.match(delivery, /必要內容骨架/);
|
||||||
|
assert.match(delivery, /產出位置/);
|
||||||
|
assert.match(delivery, /ELI5 變體規則/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('交付文件未確認或遭拒時,所有後續副作用都被阻擋', () => {
|
||||||
|
assert.match(delivery, /未確認或拒絕任何一項時,立即停止/);
|
||||||
|
assert.match(delivery, /不建立工作包、不排程、不建立相依/);
|
||||||
|
assert.match(delivery, /不寫入 Milestone、看板或其他後續資料/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PERT 與工作包優先 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('PERT 的 O、M、P 分別逐題確認,且不能用單一人天替代', () => {
|
||||||
|
const pert = prompt.slice(prompt.indexOf('### PERT 三點估算'), prompt.indexOf('## 第二段'));
|
||||||
|
assert.match(pert, /樂觀時間(O)/);
|
||||||
|
assert.match(pert, /最可能時間(M)/);
|
||||||
|
assert.match(pert, /悲觀時間(P)/);
|
||||||
|
assert.match(pert, /分別逐題詢問/);
|
||||||
|
assert.match(pert, /不得用單一人天估算代替/);
|
||||||
|
assert.match(pert, /三個值都確認後才納入排程資料/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('交付文件工作包優先,但以先決關係的拓撲順序為準', () => {
|
||||||
|
assert.match(packages, /先放已確認的交付文件工作包,再放純程式碼工作包/);
|
||||||
|
assert.match(packages, /不違反先決關係/);
|
||||||
|
assert.match(packages, /拓撲順序/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每個工作包只承擔一種交付並保留驗收所需欄位', () => {
|
||||||
|
assert.match(packages, /每顆工作包只做一種交付/);
|
||||||
|
assert.match(packages, /文件類型、必要內容、產出位置與驗收方式/);
|
||||||
|
assert.match(packages, /對應已確認交付項目的待辦置於第一項/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 建立與排程邊界 ────────────────────────────────────────────────
|
||||||
|
test('重跑需求會以既有議題與標題查重,不建立重複工作包', () => {
|
||||||
|
assert.match(packages, /重跑同一需求時,先以既有議題與標題查重/);
|
||||||
|
assert.match(packages, /不建立重複工作包/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('工作包建立與排程仍要求 dry-run,且排程在相依確認後', () => {
|
||||||
|
assert.match(packages, /issue-create\.js --dry-run/);
|
||||||
|
assert.match(schedule, /所有工作包建立且相依關係確認後/);
|
||||||
|
assert.match(schedule, /schedule\.js/);
|
||||||
|
assert.match(schedule, /各腳本先 dry-run,再實跑/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('共識、交付確認與 PERT 完成前不建立任何後續資料', () => {
|
||||||
|
assert.match(boundary, /共識摘要、交付文件確認與 PERT 三點估算完成前/);
|
||||||
|
assert.match(boundary, /不建立任何工作包、不排程、不寫入後續資料/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API 契約不寫入目標專案,且流程維持終端摘要與平台中立', () => {
|
||||||
|
assert.match(delivery, /API 契約文件只能交付預覽或使用者確認的位置/);
|
||||||
|
assert.match(delivery, /禁止寫入目標專案 repo/);
|
||||||
|
assert.match(prompt, /摘要只印終端,不寫入 Gitea/);
|
||||||
|
assert.match(boundary, /不把 API 契約文件寫入目標專案 repo/);
|
||||||
|
assertNeutralPrompt(prompt, 'sdlc-analyze');
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
||||||
|
|
||||||
|
const prompt = readPrompt('sdlc-feat');
|
||||||
|
const 分流 = prompt.slice(prompt.indexOf('## 交付文件待辦分流'), prompt.indexOf('## 實作與交付'));
|
||||||
|
|
||||||
|
// ── 正本基本契約 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('正本平台中立,description 前綴正確', () => {
|
||||||
|
assertNeutralPrompt(prompt, 'sdlc-feat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('輸入先抽取工作包並保留留言接回流程', () => {
|
||||||
|
assert.match(prompt, /scripts\/wp-extract\.js/);
|
||||||
|
assert.match(prompt, /未處理留言數/);
|
||||||
|
assert.match(prompt, /重新抽取一次/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('保留 worktree、依賴、驗收與 PR 的既有交付流程', () => {
|
||||||
|
assert.match(prompt, /依工作包 `repos` 逐一準備 worktree/);
|
||||||
|
assert.match(prompt, /立即勾選對應驗收/);
|
||||||
|
assert.match(prompt, /scripts\/pr-create\.js --dry-run/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 交付文件分流 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('交付文件待辦與程式碼待辦走不同流程', () => {
|
||||||
|
assert.match(分流, /判定為程式碼待辦或交付文件待辦/);
|
||||||
|
assert.match(分流, /不可共用無差別的實作流程/);
|
||||||
|
assert.match(分流, /references\/delivery-types\.md/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每份文件產出前逐一列骨架並一次確認一題', () => {
|
||||||
|
assert.match(分流, /每一份交付文件都要分開處理/);
|
||||||
|
assert.match(分流, /必要內容骨架、已知來源、未決事項、建議與理由/);
|
||||||
|
assert.match(分流, /一次只問一題確認內容是否齊全/);
|
||||||
|
assert.match(分流, /未確認的文件不可標成已交付/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('預覽能力以可開啟可分享能力判斷,具備時直接產出', () => {
|
||||||
|
assert.match(分流, /即時發佈成可開啟、可分享的預覽頁/);
|
||||||
|
assert.match(分流, /具備能力後直接產出預覽/);
|
||||||
|
assert.match(分流, /回報實際位置與內容摘要/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('預覽能力不足時一次一題詢問,建議由情境推導', () => {
|
||||||
|
assert.match(分流, /無法證明具備能力、預覽不可開啟或不可分享/);
|
||||||
|
assert.match(分流, /一次只問一題/);
|
||||||
|
assert.match(分流, /依文件、來源與限制提出建議及理由/);
|
||||||
|
assert.match(分流, /保留手動輸入/);
|
||||||
|
assert.match(分流, /不替使用者決定交付方式/);
|
||||||
|
assert.match(分流, /不要靠固定清單或猜測環境/);
|
||||||
|
assert.match(prompt, /不產生固定選項清單/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ELI5 與契約邊界 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('ELI5 保留技術意義並把圖表改成圖片式視覺', () => {
|
||||||
|
assert.match(分流, /保留原文件的範圍、順序、相依、例外與驗收意義/);
|
||||||
|
assert.match(分流, /不刪除技術限制/);
|
||||||
|
assert.match(分流, /重新繪製成容易閱讀的圖片式視覺/);
|
||||||
|
assert.match(分流, /不把 Mermaid 原碼當成交付物/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API 契約只在預覽或確認位置交付,不寫入目標 repo', () => {
|
||||||
|
assert.match(分流, /API 契約文件只能交付在預覽位置或使用者確認的位置/);
|
||||||
|
assert.match(分流, /不得寫入目標專案 repo/);
|
||||||
|
assert.match(分流, /不捏造網址/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assertNeutralPrompt,
|
||||||
|
assertTemplateSections,
|
||||||
|
readPrompt,
|
||||||
|
readTemplate,
|
||||||
|
} from './helpers/prompt-doc.js';
|
||||||
|
|
||||||
|
const prompt = readPrompt('sdlc-plan');
|
||||||
|
const template = readTemplate('requirement-issue');
|
||||||
|
const SECTIONS = ['總覽', '背景', '目標', '非目標', '領域名詞表', '文件', '驗收標準', '影響範圍', '未決事項'];
|
||||||
|
|
||||||
|
test('plan prompt 使用文件段落並維持繁體中文', () => {
|
||||||
|
assertNeutralPrompt(prompt, 'sdlc-plan');
|
||||||
|
assert.match(prompt, /領域名詞表、文件、驗收標準/);
|
||||||
|
assert.match(prompt, /全程使用繁體中文/);
|
||||||
|
assert.doesNotMatch(prompt, /流程圖/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('文件段落只允許 analyze 佔位或抽象節點與邊', () => {
|
||||||
|
assert.match(prompt, /`待 \/sdlc-analyze 產生`/);
|
||||||
|
assert.match(prompt, /抽象節點與邊的文字描述/);
|
||||||
|
assert.match(prompt, /不寫具體圖形語法/);
|
||||||
|
assert.match(prompt, /禁止產生 HTML、SVG、manifest、截圖、附件或任何平台 preview/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plan 重跑沿用同標題議題,不建立重複議題', () => {
|
||||||
|
assert.match(prompt, /重跑以標題查重,不建立重複議題/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('需求議題模板與 plan prompt 使用相同九段落', () => {
|
||||||
|
assertTemplateSections(template, SECTIONS);
|
||||||
|
assert.match(template, /\{\{文件\}\}/);
|
||||||
|
assert.doesNotMatch(template, /^## 流程圖$/m);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user