Merge pull request 'feat(overview-artifact): 產生可預覽總覽與截圖 fallback' (#66) from feat/overview-html-capture/main into master
Reviewed-on: #66 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #66.
This commit is contained in:
@@ -17,9 +17,9 @@
|
|||||||
| 目錄 | 職責 | 邊界 |
|
| 目錄 | 職責 | 邊界 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `prompts/` | 流程正本(`sdlc-{plan,analyze,feat,fix,sync,report}.md`),唯一的事實來源 | 平台中立 markdown,不含任何平台專屬語法 |
|
| `prompts/` | 流程正本(`sdlc-{plan,analyze,feat,fix,sync,report}.md`),唯一的事實來源 | 平台中立 markdown,不含任何平台專屬語法 |
|
||||||
| `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` |
|
| `scripts/` | 所有副作用、抽取、schema 驗證與 artifact 產生 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs`;HTML、SVG、manifest 與附件生命週期也由此處負責 |
|
||||||
| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 |
|
| `templates/` | Markdown 產出格式(議題、PR、報表) | 以 `{{變數}}` 佔位,不含邏輯;HTML artifact 由 `scripts/overview-render.js` 產生 |
|
||||||
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單、委派判準) | 由流程正本指名讀取,不自行散落於 prompts |
|
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單、委派判準、artifact 契約) | 由流程正本指名讀取,不自行散落於 prompts |
|
||||||
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
|
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
|
||||||
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
|
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
|
||||||
| `scripts/install-verify.js` | 安裝後走一遍叫用鏈(轉接檔 → PATH 上的 tea-sdlc → 流程正本) | 只認拿到的轉接檔路徑,不自己推導平台目錄;不碰網路 |
|
| `scripts/install-verify.js` | 安裝後走一遍叫用鏈(轉接檔 → PATH 上的 tea-sdlc → 流程正本) | 只認拿到的轉接檔路徑,不自己推導平台目錄;不碰網路 |
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# 以集中式雜湊路徑的 worktree 隔離平行工作包
|
||||||
|
|
||||||
|
`/sdlc-feat` 與 `/sdlc-fix` 一律在獨立的 worktree 上工作,而非在同一個工作目錄上切換分支;worktree 集中於 `~/.tea-sdlc/worktrees/{hash}`,`hash` 為正規化後 `owner/repo/分支名` 的 sha256 前 12 碼。這麼做的核心理由是 agent 非同步讀檔:它可能在分支被切走之後才去讀,而它不會察覺自己讀到的是別顆工作包的程式碼,產出看似合理卻接錯上下文——前兩種常見損耗(未提交變更擋路、建置產物跨分支混淆)人會當場發現,這一種不會,所以規則是「一律」而非「有衝突才用」。
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **repo 內的 `.worktrees/`**:未被忽略時會出現在目標專案的 `git status`,而要它不出現就得改目標專案的忽略設定——本 plugin 明文不修改目標專案的檔案。
|
||||||
|
- **目標 repo 的兄弟目錄**:不污染 repo,但會在使用者的專案父目錄長出一堆目錄,而那個目錄結構屬於使用者,不屬於這個工具。
|
||||||
|
- **把分支名的斜線攤平成 `-` 當目錄名**:`feat/a-b/main` 與 `feat/a/b/main` 會撞成同一個名字,而既有的分支命名規則(`{類型}/{需求描述}/{功能描述}`)恰好讓這種形狀有機會出現。
|
||||||
|
- **目錄名加可讀後綴**:被否決,因為 `git worktree list` 本來就會把分支名印在路徑旁邊,可讀性缺口有限。
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- 路徑由工作包**純函式推導**,不需要任何本機對照表或狀態檔,因此換機器或換 agent 之後推導結果相同;推導不到就重建,這正是「不寫入任何本機狀態檔」這條既有約束所要求的接手方式。
|
||||||
|
- `git worktree add` 仍會在目標 repo 的 `.git/worktrees/` 底下寫中繼資料。這是 git 的機制,無法避免。「不修改目標專案的檔案」指的是專案內容檔,不含 git 自身的內部中繼資料——這條界線是本決策劃定的。
|
||||||
|
- 目錄名是雜湊,光看路徑字串認不出是哪顆工作包。緩解來自兩處:`git worktree list` 印出分支名,PR 檢查腳本回報推導出的路徑。
|
||||||
|
- 正規化(小寫、去前後空白)是必要的:沒有它,同一棵 worktree 會因輸入大小寫或多一個空白而被推導成兩個不同路徑。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# 以能力描述而非工具名表達委派
|
||||||
|
|
||||||
|
流程正本在只在意結果的步驟上標記 `〔可委派〕`,並以**能力描述**說明怎麼委派——「你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做」——而不指名任何平台的子代理工具。子代理是平台專屬能力(Claude Code 與 Codex 有,Copilot/Kiro/OpenCode 不一定),而流程正本必須保持平台中立:這是 #4 已交付並打勾的驗收標準,也是 `AGENTS.md` 的模組邊界之一。能力描述對不支援的平台是自然降級,同一份正本兩邊都讀得通,不需要維護兩份。
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **正本直接寫平台工具名**:最精確、agent 最不會誤判,但直接違反「流程正本不含任何平台專屬語法」,並且要為七個平台維護分歧的正本——那正是這個專案立「流程正本只有一份」時要防的事。
|
||||||
|
- **由 `install.js` 產生轉接檔時依平台注入**:轉接檔只有一行指回正本,塞不下步驟級的指示;而「哪些步驟可委派」是流程知識,搬進 `install.js` 會污染它「唯一知道各平台目錄結構」的單一職責。
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- 這個寫法**刻意比它能做到的更模糊**。下一個讀到它的人第一反應很可能是「為什麼不直接寫工具名?」然後就把它改掉——這顆 ADR 存在的主要目的就是攔下那個修改。
|
||||||
|
- 委派的判準(四條,見 `references/delegation.md`)與正本上的標記必須靠測試綁在一起:資產測試斷言「正本上被標記的步驟集合等於判準文件列出的集合」。沒有這條雙向斷言,兩邊會漂開,而漂開時不會有任何東西報錯。
|
||||||
|
- 判準第二條(步驟中不會詢問使用者)與第四條(只產出草稿或唯讀結果,不直接寫入 Gitea 或 git)是硬排除,不是建議。前者因為子代理問不到使用者,後者因為子代理的失敗沒有人看著——備妥工作樹失敗會中止整個領取、實際提交失敗會留下半套 git 歷史,兩者都需要當場有人判斷下一步。
|
||||||
+46
-31
@@ -216,32 +216,28 @@ node scripts/project-add.js --repo <owner/name> --index <編號> --project "<看
|
|||||||
|
|
||||||
### 12. 產生分析版的圖解總覽 〔可委派〕
|
### 12. 產生分析版的圖解總覽 〔可委派〕
|
||||||
|
|
||||||
用同一份 `templates/overview-artifact.html` 再產一份,但這一份要多出**工作包全景**:
|
排程完成後重新抽取需求與全部工作包,組成 `schemaVersion: 1` 的需求級 JSON。
|
||||||
把工作包之間的相依與截止日畫成一張圖,讓開發者看得出自己這一項在整體中的位置。
|
工作包依賴與截止日必須來自重新抽取的實際資料,不使用模型記憶中的暫定值。
|
||||||
|
執行:
|
||||||
全景圖用 `graph TD`,填進 `{{工作包全景}}`,連同段落標題一起:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<section><h2>工作包全景</h2>
|
node scripts/overview-render.js --input <json> --output <html> --manifest <manifest>
|
||||||
<figure><div class="mermaid">graph TD
|
|
||||||
A[建立共用函式庫<br/>09-25] --> B[建立抽取契約<br/>09-27]
|
|
||||||
</div></figure></section>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
節點寫工作包標題與截止日,箭頭方向是「先決 → 後續」。節點一樣以 12 個為上限,
|
HTML 內重新產生 SVG 與 HTML/CSS 視圖;不能直接搬用議題 Mermaid。預覽優先使用平台
|
||||||
超過就只畫相依鏈最長路徑上的那幾顆,其餘在頁尾列成文字。
|
能力,否則啟動短命 Node server 供瀏覽器截圖。產生 full-page 與局部圖 PNG 後執行:
|
||||||
|
|
||||||
委派的是**產出那份 HTML**;拿到網址之後寫回議題那一步**不委派**(判準第四條)。
|
|
||||||
|
|
||||||
網址一樣寫回需求議題:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
node scripts/issue-update.js --repo <owner/name> --index <需求議題編號> --overview-url <網址>
|
node scripts/issue-assets.js --repo <owner/name> --index <需求議題編號> --manifest <manifest>
|
||||||
```
|
```
|
||||||
|
|
||||||
**重跑會就地更新同一行**,不會在議題上留下兩個連結。規劃階段產生的那一份會被這一份取代,
|
截圖全部上傳成附件,需求議題正文只保留附件索引。只有真正可用的 preview URL 才使用
|
||||||
這是預期行為——同一顆需求議題只掛一個總覽網址。
|
既有 `--overview-url`;不可把 `.tmp/` 或 `0.0.0.0` 寫回 Gitea。
|
||||||
|
產出 HTML 可委派;預覽截圖、附件上傳與任何 `issue-assets`/`issue-update` 寫回都不委派,
|
||||||
|
由主流程執行並處理錯誤。
|
||||||
|
若平台 preview 不可用,執行 `node scripts/overview-capture.js --backend auto`;
|
||||||
|
它依序嘗試 Firefox,再嘗試 `rsvg-convert`/`resvg`。所有 backend 都不可用時必須
|
||||||
|
停止並保留 `.tmp/` 的 HTML/SVG,不得宣稱截圖完成。
|
||||||
### 13. 停錶並回報
|
### 13. 停錶並回報
|
||||||
|
|
||||||
回報之前先停錶,這一段計時到此為止:
|
回報之前先停錶,這一段計時到此為止:
|
||||||
@@ -267,19 +263,9 @@ Gitea 1.27 的 API 沒有任何請求定義接受 `time_estimate`,該欄位只
|
|||||||
|
|
||||||
## 架構圖的限制
|
## 架構圖的限制
|
||||||
|
|
||||||
依工作包的性質選圖:
|
圖表以抽象 `kind`、`direction`、`nodes`、`edges` 保存。renderer 產生議題 Mermaid
|
||||||
|
與 HTML SVG;不能讓模型直接維護兩套圖表語法。節點超過 12、文字超過 8 字或無法
|
||||||
- **`sequenceDiagram`** — 重點在「誰呼叫誰、順序為何」時用。
|
安全轉換時,拆圖或記錄 `omitted` 原因。
|
||||||
- **`flowchart`** — 重點在「條件分支與資料流向」時用。
|
|
||||||
- **`stateDiagram-v2`** — 重點在「狀態怎麼轉移」時用。
|
|
||||||
|
|
||||||
節點數上限 **12**,每個節點的文字上限 **8 字**。超過就拆成多張圖,或者乾脆不畫。
|
|
||||||
|
|
||||||
模板的 `{{架構圖}}` 要填入**完整的內容**,兩種形式擇一:
|
|
||||||
|
|
||||||
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
|
|
||||||
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫,**不要加圍欄**。
|
|
||||||
|
|
||||||
## 邊界
|
## 邊界
|
||||||
|
|
||||||
- **共識摘要之前不對 Gitea 寫入任何內容**:不建議題、不改描述、不貼標籤、不留留言。
|
- **共識摘要之前不對 Gitea 寫入任何內容**:不建議題、不改描述、不貼標籤、不留留言。
|
||||||
@@ -292,3 +278,32 @@ Gitea 1.27 的 API 沒有任何請求定義接受 `time_estimate`,該欄位只
|
|||||||
- 計時只動這顆需求議題:在它上面起錶、在它上面停錶。**不停別顆議題上的錶**,
|
- 計時只動這顆需求議題:在它上面起錶、在它上面停錶。**不停別顆議題上的錶**,
|
||||||
被別顆的錶擋下時交還給使用者決定,不繞過去。
|
被別顆的錶擋下時交還給使用者決定,不繞過去。
|
||||||
- 不關閉或刪除任何既有議題。
|
- 不關閉或刪除任何既有議題。
|
||||||
|
# Artifact 交接與視圖
|
||||||
|
|
||||||
|
完成第三段排程後,依 `references/artifact-contract.md` 重新抽取需求與全部工作包,
|
||||||
|
組成一份 `schemaVersion: 1` 的 JSON 暫存檔。這份 JSON 是 HTML renderer 的輸入,
|
||||||
|
不是 Gitea 的第二份正本。
|
||||||
|
|
||||||
|
先執行:
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/overview-render.js --input <json> --output <html> --manifest <manifest>
|
||||||
|
```
|
||||||
|
|
||||||
|
renderer 驗證失敗時,不截圖、不上傳、不更新議題,保留 `.tmp/` 的輸入與錯誤供修正。
|
||||||
|
HTML 必須自包含,不依賴 CDN;圖表由抽象節點/邊資料重新產生 SVG,不能直接搬用議題
|
||||||
|
Mermaid。公式是可選欄位,單一公式無法渲染時保留原文並標示未渲染。
|
||||||
|
|
||||||
|
預覽優先使用執行環境提供的預覽能力。沒有預覽能力時,使用 `0.0.0.0` 綁定的
|
||||||
|
ephemeral Node 靜態伺服器,只為瀏覽器截圖,完成後一定停止。產生 full-page 與所有
|
||||||
|
局部圖 PNG,為每張圖填入 manifest 的角色、固定檔名與 SHA-256。
|
||||||
|
|
||||||
|
截圖全部是需求議題附件,不嵌入圖片;執行:
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/issue-assets.js --repo <owner/name> --index <需求議題編號> --manifest <manifest>
|
||||||
|
```
|
||||||
|
|
||||||
|
這支腳本負責 hash 重用、附件上傳與「圖解版總覽附件」索引的冪等更新。上傳或索引
|
||||||
|
更新失敗就是流程失敗。只有預覽平台提供真正可用的 URL 時,才另外使用既有的
|
||||||
|
`--overview-url`;不可把 `.tmp/` 或 `0.0.0.0` 寫回 Gitea。
|
||||||
|
|||||||
@@ -364,3 +364,12 @@ node scripts/worktree-remove.js --repo <owner/name> --branch <分支名>
|
|||||||
- 不替使用者決定來源分支。
|
- 不替使用者決定來源分支。
|
||||||
- **不寫任何本機狀態檔。** 進度完全由 Gitea 上的 assignee、標籤、碼錶與 git 本身推導,
|
- **不寫任何本機狀態檔。** 進度完全由 Gitea 上的 assignee、標籤、碼錶與 git 本身推導,
|
||||||
換一台機器或換一個 agent 都要能直接接手。
|
換一台機器或換一個 agent 都要能直接接手。
|
||||||
|
|
||||||
|
## 交接規格閘門
|
||||||
|
|
||||||
|
讀取工作包後,若它有介面契約,先完成第一個規格待辦:填妥介面、產出者、
|
||||||
|
消費者、形狀四欄,並附上該 `interfaceType` 的範例資料。四欄表格與範例資料
|
||||||
|
是同一待辦下的兩個驗收;兩者完成前不得進入後續程式實作。
|
||||||
|
|
||||||
|
資料、架構、排程工作包依其 `type` 先完成對應規格待辦;純內部小型實作可直接
|
||||||
|
進入下一個待辦。規格仍寫回工作包議題既有段落,不另建本機正本。
|
||||||
|
|||||||
+19
-39
@@ -134,32 +134,15 @@ node scripts/timer.js --repo <owner/name> --index <編號> --dry-run
|
|||||||
|
|
||||||
### 8. 產生圖解版總覽 〔可委派〕
|
### 8. 產生圖解版總覽 〔可委派〕
|
||||||
|
|
||||||
套用 `templates/overview-artifact.html`,把議題的總覽、目標與流程圖填成一份可以直接投影的
|
依 `references/artifact-contract.md` 從需求議題抽取結果組成 `schemaVersion: 1` JSON,
|
||||||
網頁。這一份是給**非技術的利害關係人**看的:他們不必讀完技術細節就知道這件事在做什麼。
|
執行 `node scripts/overview-render.js` 產生自包含 HTML 與 manifest。HTML 只給使用者檢視,
|
||||||
|
不取代需求議題的詳細內容。若平台有 preview 能力就使用它;否則以短命 Node server
|
||||||
|
服務 `.tmp/` 檔案供瀏覽器截圖。只有真正可用的 preview URL 才執行既有
|
||||||
|
`node scripts/issue-update.js --overview-url <網址>`。
|
||||||
|
產出 HTML 可委派;預覽截圖、附件上傳與任何 `issue-update` 寫回都不委派,由主流程
|
||||||
|
執行並處理錯誤。
|
||||||
|
|
||||||
模板的佔位對應如下,樣式不要動——版面與內容分開,改一邊不必碰另一邊:
|
規劃階段沒有工作包,因此 `workPackages` 為空陣列,不產生工作包依賴圖。
|
||||||
|
|
||||||
- `{{標題}}` 需求議題標題
|
|
||||||
- `{{來源議題}}` 指回議題的連結
|
|
||||||
- `{{總覽}}` 一句話總覽
|
|
||||||
- `{{目標}}` 目標,逐條包成 `<li>`
|
|
||||||
- `{{流程圖}}` 流程圖的 Mermaid 原始碼(**不含**圍欄,圍欄是議題 markdown 用的)
|
|
||||||
- `{{工作包全景}}` 規劃階段還沒有工作包,**填空字串**;這一段由分析階段補上
|
|
||||||
- `{{頁尾}}` 產生時間與產生者
|
|
||||||
|
|
||||||
若執行環境能把 HTML 發佈成可分享的網址,就發佈;不能的話存成檔案,把路徑當成網址用。
|
|
||||||
|
|
||||||
委派的是**產出那份 HTML**;拿到網址之後寫回議題那一步**不委派**(判準第四條)。
|
|
||||||
|
|
||||||
拿到網址後寫回議題:
|
|
||||||
|
|
||||||
```
|
|
||||||
node scripts/issue-update.js --repo <owner/name> --index <編號> --overview-url <網址>
|
|
||||||
```
|
|
||||||
|
|
||||||
它把連結以固定前綴寫成總覽段落裡的一行,**重跑時就地更新同一行**,不會長出第二個連結;
|
|
||||||
議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。連結旁會自動附上
|
|
||||||
「此連結預設為私有,組織外無法開啟」,因為讀到的人多半會想轉寄給組織外的人。
|
|
||||||
|
|
||||||
### 9. 停錶並回報
|
### 9. 停錶並回報
|
||||||
|
|
||||||
@@ -173,22 +156,11 @@ node scripts/timer.js --repo <owner/name> --index <編號> --stop
|
|||||||
說明),回報照樣做完。
|
說明),回報照樣做完。
|
||||||
|
|
||||||
把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。
|
把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。
|
||||||
|
|
||||||
## 流程圖的限制
|
## 流程圖的限制
|
||||||
|
|
||||||
用 Mermaid 的 `flowchart`。節點數上限 **12**,每個節點的文字上限 **8 字**。
|
流程圖的抽象節點與邊保存於 JSON,由 renderer 重新產生 SVG;議題若需要保存圖表,
|
||||||
|
由同一個 renderer 產生 Mermaid。節點數超過 12 或文字超過 8 字時拆圖或記錄
|
||||||
超過就拆成多張圖,或者乾脆不畫 —— 一張塞了二十個節點的圖,比沒有圖更難懂。
|
`omitted` 原因,不由模型任意壓縮語意。
|
||||||
|
|
||||||
節點文字寫該步驟在做什麼,不要寫成編號或代號。
|
|
||||||
|
|
||||||
模板的 `{{流程圖}}` 要填入**完整的內容**,兩種形式擇一:
|
|
||||||
|
|
||||||
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
|
|
||||||
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。
|
|
||||||
|
|
||||||
圍欄寫在填入的內容裡而不是模板裡,否則不畫圖時會留下一個空的 mermaid 區塊,
|
|
||||||
在議題頁上是一塊渲染失敗的紅字。
|
|
||||||
|
|
||||||
## 邊界
|
## 邊界
|
||||||
|
|
||||||
@@ -198,3 +170,11 @@ node scripts/timer.js --repo <owner/name> --index <編號> --stop
|
|||||||
- 規劃階段本身已含問題釐清,因此寫入 Gitea 前不再設額外的確認點;`--dry-run` 就是那道關卡。
|
- 規劃階段本身已含問題釐清,因此寫入 Gitea 前不再設額外的確認點;`--dry-run` 就是那道關卡。
|
||||||
- 計時只動這顆需求議題:在它上面起錶、在它上面停錶、把規劃時間補登在它上面。
|
- 計時只動這顆需求議題:在它上面起錶、在它上面停錶、把規劃時間補登在它上面。
|
||||||
**不停別顆議題上的錶**,被別顆的錶擋下時交還給使用者決定,不繞過去。
|
**不停別顆議題上的錶**,被別顆的錶擋下時交還給使用者決定,不繞過去。
|
||||||
|
|
||||||
|
## Artifact 產物規則
|
||||||
|
|
||||||
|
圖解總覽不再套用 HTML 模板或依賴 Mermaid CDN。若此階段需要產生預覽,
|
||||||
|
先依 `references/artifact-contract.md` 組成需求級 JSON,再執行
|
||||||
|
`node scripts/overview-render.js`。HTML、SVG 與 manifest 只寫入 `.tmp/`;
|
||||||
|
只有真正可用的 preview URL 才能使用既有 `--overview-url` 回寫。規劃階段沒有
|
||||||
|
工作包時,`workPackages` 必須是空陣列,工作包全景不產生。
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Artifact 產物契約
|
||||||
|
|
||||||
|
這份規則是需求級 HTML、截圖與議題附件的唯一正本。
|
||||||
|
|
||||||
|
## 資料流
|
||||||
|
|
||||||
|
1. `/sdlc-analyze` 完成排程後重新取得需求與工作包抽取 JSON。
|
||||||
|
2. 流程組成一個 `schemaVersion: 1` 的需求根物件,暫存於 `.tmp/`。
|
||||||
|
3. `scripts/overview-render.js --input <json> --output <html> --manifest <manifest>` 驗證並產生自包含 HTML。
|
||||||
|
4. 預覽平台優先;沒有預覽能力時,使用可用 ephemeral port 短暫啟動 Node 靜態伺服器。
|
||||||
|
5. 瀏覽器產生 full-page 與圖表局部 PNG,寫入 manifest。
|
||||||
|
6. `scripts/issue-assets.js --repo ... --index ... --manifest ...` 上傳附件並就地更新需求議題附件索引。
|
||||||
|
|
||||||
|
JSON、HTML、SVG 與 PNG 全部寫入 `.tmp/`,不寫入目標專案。
|
||||||
|
|
||||||
|
## 根物件
|
||||||
|
|
||||||
|
必要欄位:`schemaVersion`、`source`、`requirement`、`workPackages`、`overview`。
|
||||||
|
|
||||||
|
`schemaVersion` 不是目前 renderer 支援的版本時必須停止;未知欄位保留。
|
||||||
|
|
||||||
|
## 圖表
|
||||||
|
|
||||||
|
圖表以抽象資料保存:`kind`、`direction`、`nodes`、`edges`。議題輸出由 renderer 產生 Mermaid;HTML 重新產生 SVG 或 HTML/CSS,不得直接搬用 Mermaid。
|
||||||
|
|
||||||
|
流程、依賴、架構與網路圖使用 SVG;卡片與清單使用 HTML/CSS。布局必須固定且可重跑。
|
||||||
|
|
||||||
|
## 公式
|
||||||
|
|
||||||
|
公式是可選欄位,保存 LaTeX 原文、標題、說明與用途。議題使用 `formula` fenced block;HTML 使用自包含 SVG。單一公式解析失敗時保留原文並標記未渲染,不得靜默遺失;公式欄位結構錯誤仍使整體 schema 驗證失敗。
|
||||||
|
|
||||||
|
## 截圖與附件
|
||||||
|
|
||||||
|
manifest 的每個 screenshot 必須包含 `role`、`path`、`fileName`、`sha256`。PNG 使用固定語意檔名,內容 hash 用 SHA-256。
|
||||||
|
|
||||||
|
full-page 與局部圖全部上傳為需求議題附件,不直接嵌入圖片。正文只保留一行附件索引;重跑時內容相同就重用,無法確認 hash 就新增附件。附件上傳或議題索引更新失敗是硬錯誤。
|
||||||
|
|
||||||
|
若平台提供真正可用的 preview URL,才使用既有 `--overview-url` 回寫;不可把 `.tmp/` 或 `0.0.0.0` 寫回議題。
|
||||||
|
|
||||||
|
## 截圖 backend
|
||||||
|
|
||||||
|
平台 preview 是首選。沒有平台 preview 時,可執行:
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/overview-capture.js --backend auto --url <preview-url> --output <png>
|
||||||
|
```
|
||||||
|
|
||||||
|
若沒有瀏覽器但有 SVG rasterizer:
|
||||||
|
|
||||||
|
```
|
||||||
|
node scripts/overview-capture.js --backend svg --svg <overview.svg> --output <png>
|
||||||
|
```
|
||||||
|
|
||||||
|
`auto` 優先使用 Firefox,再使用 `rsvg-convert` 或 `resvg`。找不到任何 backend
|
||||||
|
必須失敗並保留 `.tmp/` 的 HTML/SVG,不得假裝已完成截圖。
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Upload overview screenshots to a Gitea issue and update its attachment index.
|
||||||
|
* The manifest is produced by the preview step and contains local PNG paths and hashes.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import {
|
||||||
|
ScriptError,
|
||||||
|
expectOk,
|
||||||
|
fetchIssue,
|
||||||
|
giteaRequest,
|
||||||
|
giteaUpload,
|
||||||
|
main,
|
||||||
|
parseFlags,
|
||||||
|
parseIndex,
|
||||||
|
parseRepo,
|
||||||
|
preflight,
|
||||||
|
resolveLogin,
|
||||||
|
} from './lib.js';
|
||||||
|
import { upsertLineInSection } from './issue-body.js';
|
||||||
|
|
||||||
|
const INDEX_LINE = '圖解版總覽附件:';
|
||||||
|
|
||||||
|
main(async () => {
|
||||||
|
const flags = parseFlags(process.argv.slice(2), {
|
||||||
|
required: ['repo', 'index', 'manifest'],
|
||||||
|
optional: ['host'],
|
||||||
|
booleans: ['dry-run'],
|
||||||
|
});
|
||||||
|
const repo = parseRepo(flags.repo);
|
||||||
|
const index = parseIndex(flags.index);
|
||||||
|
const manifest = JSON.parse(readFileSync(resolve(flags.manifest), 'utf8'));
|
||||||
|
const screenshots = manifest.screenshots;
|
||||||
|
if (!Array.isArray(screenshots) || screenshots.length === 0) {
|
||||||
|
throw new ScriptError('MANIFEST_INVALID', 'manifest 必須包含至少一張 screenshots');
|
||||||
|
}
|
||||||
|
for (const item of screenshots) {
|
||||||
|
if (!item.role || !item.path || !item.fileName || !item.sha256 || !existsSync(resolve(item.path))) {
|
||||||
|
throw new ScriptError('MANIFEST_INVALID', '每張截圖都必須有 role、path、fileName、sha256 且檔案存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const login = resolveLogin({ host: flags.host });
|
||||||
|
const issuePath = `/repos/${repo}/issues/${index}`;
|
||||||
|
const assetsPath = `${issuePath}/assets`;
|
||||||
|
if (flags['dry-run']) {
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
repo,
|
||||||
|
index,
|
||||||
|
requests: [
|
||||||
|
{ method: 'GET', path: issuePath },
|
||||||
|
{ method: 'GET', path: assetsPath },
|
||||||
|
...screenshots.map((item) => ({ method: 'POST', path: assetsPath, file: item.path })),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await preflight(login, repo);
|
||||||
|
const issue = await fetchIssue(login, repo, index);
|
||||||
|
const assets = expectOk(await giteaRequest(login, 'GET', assetsPath), `GET ${assetsPath}`);
|
||||||
|
const existing = Array.isArray(assets) ? assets : [];
|
||||||
|
const indexed = parseIndexedAssets(issue.body ?? '');
|
||||||
|
const uploaded = [];
|
||||||
|
for (const item of screenshots) {
|
||||||
|
const indexedAsset = indexed.find((asset) => asset.fileName === item.fileName && asset.sha256 === item.sha256);
|
||||||
|
const existingAsset = existing.find((asset) => asset.name === item.fileName && asset.sha256 === item.sha256);
|
||||||
|
const asset = indexedAsset ?? existingAsset;
|
||||||
|
if (asset) {
|
||||||
|
uploaded.push({ ...item, url: asset.url ?? asset.browser_download_url ?? asset.download_url, reused: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const response = expectOk(
|
||||||
|
await giteaUpload(login, `${assetsPath}?name=${encodeURIComponent(item.fileName)}`, resolve(item.path), item.fileName),
|
||||||
|
`POST ${assetsPath}`,
|
||||||
|
);
|
||||||
|
uploaded.push({ ...item, url: response.browser_download_url ?? response.download_url, reused: false });
|
||||||
|
}
|
||||||
|
const body = updateIndex(issue.body ?? '', uploaded);
|
||||||
|
const update = body === issue.body ? null : expectOk(await giteaRequest(login, 'PATCH', issuePath, { body: { body } }), `PATCH ${issuePath}`);
|
||||||
|
return { repo, index, uploaded, updated: update !== null, url: update?.html_url ?? issue.html_url };
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseIndexedAssets(body) {
|
||||||
|
const line = body.split('\n').find((row) => row.startsWith(INDEX_LINE)) ?? '';
|
||||||
|
return [...line.matchAll(/([^=;\s]+)=([^;\s]+)\s+sha256=([a-f0-9]{64})\s+(\S+)/g)].map((match) => ({
|
||||||
|
role: match[1],
|
||||||
|
fileName: match[2],
|
||||||
|
sha256: match[3],
|
||||||
|
url: match[4],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIndex(body, assets) {
|
||||||
|
const lines = assets.map((asset) => `${asset.role}=${asset.fileName} sha256=${asset.sha256} ${asset.url ?? '(網址未回傳)'}`);
|
||||||
|
return upsertLineInSection(body, '總覽', `${INDEX_LINE}${lines.join(';')}`);
|
||||||
|
}
|
||||||
@@ -422,6 +422,31 @@ export async function giteaRequest(login, method, path, { body, query } = {}) {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return { status: response.status, body: text ? safeJson(text) : null };
|
return { status: response.status, body: text ? safeJson(text) : null };
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 上傳 Gitea issue attachment。這是唯一的 multipart HTTP 出口。
|
||||||
|
* @param {{base:string, token:string}} login
|
||||||
|
* @param {string} path
|
||||||
|
* @param {string} filePath
|
||||||
|
* @param {string} fileName
|
||||||
|
* @returns {Promise<{status:number, body:any}>}
|
||||||
|
*/
|
||||||
|
export async function giteaUpload(login, path, filePath, fileName) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('attachment', new Blob([readFileSync(filePath)]), fileName);
|
||||||
|
const url = new URL(`${login.base}${path}`);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `token ${login.token}`, Accept: 'application/json' },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
throw new ScriptError('NETWORK_ERROR', `連不上 Gitea(POST ${path}):${cause.message}`);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
return { status: response.status, body: safeJson(text) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把「非預期狀態碼」收斂成帶狀態碼的錯誤,成功則回傳 body。
|
* 把「非預期狀態碼」收斂成帶狀態碼的錯誤,成功則回傳 body。
|
||||||
|
|||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Capture an overview using an available non-managed backend.
|
||||||
|
* Managed browser preview remains the preferred platform-level path; this CLI handles
|
||||||
|
* Firefox and deterministic SVG rasterization when those binaries are installed.
|
||||||
|
*/
|
||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { ScriptError, main, parseFlags } from './lib.js';
|
||||||
|
|
||||||
|
main(async () => {
|
||||||
|
const flags = parseFlags(process.argv.slice(2), {
|
||||||
|
required: ['output'],
|
||||||
|
optional: ['url', 'svg', 'backend', 'width', 'height'],
|
||||||
|
});
|
||||||
|
const output = resolve(flags.output);
|
||||||
|
mkdirSync(dirname(output), { recursive: true });
|
||||||
|
const backend = chooseBackend(flags.backend, flags.url, flags.svg);
|
||||||
|
if (backend === 'firefox') captureFirefox(flags.url, output, flags.width, flags.height);
|
||||||
|
else captureSvg(flags.svg, output, flags.width, flags.height);
|
||||||
|
return { backend, output };
|
||||||
|
});
|
||||||
|
|
||||||
|
function chooseBackend(requested, url, svg) {
|
||||||
|
if (requested && requested !== 'auto' && requested !== 'firefox' && requested !== 'svg') {
|
||||||
|
throw new ScriptError('BACKEND_UNKNOWN', `不支援的 capture backend:${requested}`);
|
||||||
|
}
|
||||||
|
if (requested === 'firefox') {
|
||||||
|
requireBinary('firefox');
|
||||||
|
if (!url) throw new ScriptError('URL_REQUIRED', 'Firefox backend 需要 --url');
|
||||||
|
return 'firefox';
|
||||||
|
}
|
||||||
|
if (requested === 'svg') {
|
||||||
|
requireSvgInput(svg);
|
||||||
|
requireAnyBinary(['rsvg-convert', 'resvg']);
|
||||||
|
return 'svg';
|
||||||
|
}
|
||||||
|
if (url && hasBinary('firefox')) return 'firefox';
|
||||||
|
if (svg && (hasBinary('rsvg-convert') || hasBinary('resvg'))) return 'svg';
|
||||||
|
throw new ScriptError('NO_CAPTURE_BACKEND', '沒有可用的 Firefox 或 SVG rasterizer;請使用平台 preview,或安裝 firefox、rsvg-convert、resvg');
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureFirefox(url, output, width, height) {
|
||||||
|
const args = ['--headless'];
|
||||||
|
if (width) args.push('--window-size', `${width}${height ? `,${height}` : ''}`);
|
||||||
|
args.push('--screenshot', output, url);
|
||||||
|
const result = spawnSync('firefox', args, { encoding: 'utf8' });
|
||||||
|
if (result.status !== 0 || !existsSync(output)) throw new ScriptError('CAPTURE_FAILED', `Firefox 截圖失敗:${result.stderr || result.stdout || '沒有輸出檔案'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureSvg(svg, output, width, height) {
|
||||||
|
requireSvgInput(svg);
|
||||||
|
if (hasBinary('rsvg-convert')) {
|
||||||
|
const args = [`--output=${output}`];
|
||||||
|
if (width) args.push(`--width=${width}`);
|
||||||
|
if (height) args.push(`--height=${height}`);
|
||||||
|
args.push(resolve(svg));
|
||||||
|
const result = spawnSync('rsvg-convert', args, { encoding: 'utf8' });
|
||||||
|
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `rsvg-convert 失敗:${result.stderr || result.stdout}`);
|
||||||
|
} else {
|
||||||
|
const args = [];
|
||||||
|
if (width) args.push('-w', String(width));
|
||||||
|
args.push(resolve(svg), output);
|
||||||
|
const result = spawnSync('resvg', args, { encoding: 'utf8' });
|
||||||
|
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `resvg 失敗:${result.stderr || result.stdout}`);
|
||||||
|
}
|
||||||
|
if (!existsSync(output)) throw new ScriptError('CAPTURE_FAILED', 'rasterizer 沒有產生輸出檔案');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSvgInput(svg) {
|
||||||
|
if (!svg || !existsSync(resolve(svg))) throw new ScriptError('SVG_REQUIRED', 'SVG backend 需要存在的 --svg');
|
||||||
|
}
|
||||||
|
function requireBinary(binary) { if (!hasBinary(binary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binary}`); }
|
||||||
|
function requireAnyBinary(binaries) { if (!binaries.some(hasBinary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binaries.join(' 或 ')}`); }
|
||||||
|
function hasBinary(binary) { try { execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); return true; } catch { return false; } }
|
||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Validate an overview artifact JSON document and render a self-contained HTML file.
|
||||||
|
* The renderer never calls Gitea; callers provide a JSON snapshot from the extractors.
|
||||||
|
*/
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { ScriptError, main, parseFlags } from './lib.js';
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
const WORK_TYPES = new Set(['implementation', 'data', 'architecture', 'schedule', 'documentation']);
|
||||||
|
const DIAGRAM_KINDS = new Set(['flowchart', 'dependency', 'architecture', 'network', 'repo', 'schedule']);
|
||||||
|
|
||||||
|
export function validateOverview(document) {
|
||||||
|
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('SCHEMA_INVALID', '根資料必須是物件');
|
||||||
|
if (document.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_UNSUPPORTED', `只支援 schemaVersion ${SCHEMA_VERSION}`);
|
||||||
|
if (!document.source?.requirement?.repo || !Number.isInteger(document.source.requirement.index)) {
|
||||||
|
fail('SCHEMA_INVALID', 'source.requirement 必須包含 repo 與整數 index');
|
||||||
|
}
|
||||||
|
if (!document.requirement || typeof document.requirement !== 'object') fail('SCHEMA_INVALID', '缺少 requirement');
|
||||||
|
if (!Array.isArray(document.workPackages)) fail('SCHEMA_INVALID', 'workPackages 必須是陣列');
|
||||||
|
for (const [position, workPackage] of document.workPackages.entries()) validateWorkPackage(workPackage, position);
|
||||||
|
validateDiagrams(document.requirement.diagrams, 'requirement');
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWorkPackage(value, position) {
|
||||||
|
if (!value || typeof value !== 'object') fail('SCHEMA_INVALID', `workPackages[${position}] 必須是物件`);
|
||||||
|
for (const key of ['title', 'description', 'scope', 'issue']) {
|
||||||
|
if (typeof value[key] !== 'string' || value[key].trim() === '') fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必填`);
|
||||||
|
}
|
||||||
|
if (!WORK_TYPES.has(value.type)) fail('SCHEMA_INVALID', `workPackages[${position}].type 不支援:${value.type}`);
|
||||||
|
for (const key of ['repos', 'depends', 'todos', 'acceptance']) {
|
||||||
|
if (!Array.isArray(value[key])) fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必須是陣列`);
|
||||||
|
}
|
||||||
|
if (value.interfaces !== undefined) {
|
||||||
|
if (!Array.isArray(value.interfaces)) fail('SCHEMA_INVALID', `workPackages[${position}].interfaces 必須是陣列`);
|
||||||
|
for (const contract of value.interfaces) validateInterface(contract, position);
|
||||||
|
}
|
||||||
|
validateDiagrams(value.diagrams, `workPackages[${position}]`);
|
||||||
|
if (value.formulas !== undefined && !Array.isArray(value.formulas)) fail('SCHEMA_INVALID', `workPackages[${position}].formulas 必須是陣列`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInterface(contract, position) {
|
||||||
|
const kinds = {
|
||||||
|
http: ['method', 'path', 'request', 'response', 'errors', 'example'],
|
||||||
|
cli: ['command', 'args', 'stdout', 'stderr', 'exitCodes', 'example'],
|
||||||
|
function: ['signature', 'input', 'output', 'errors', 'example'],
|
||||||
|
event: ['topic', 'payload', 'producer', 'consumer', 'delivery', 'errors', 'example'],
|
||||||
|
storage: ['operation', 'entity', 'schema', 'constraints', 'transaction', 'example'],
|
||||||
|
};
|
||||||
|
if (!kinds[contract?.interfaceType]) fail('SCHEMA_INVALID', `workPackages[${position}] 有未知 interfaceType`);
|
||||||
|
for (const key of kinds[contract.interfaceType]) {
|
||||||
|
if (contract[key] === undefined) fail('SCHEMA_INVALID', `介面契約缺少 ${contract.interfaceType}.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDiagrams(diagrams, owner) {
|
||||||
|
if (diagrams === undefined) return;
|
||||||
|
if (!Array.isArray(diagrams)) fail('SCHEMA_INVALID', `${owner}.diagrams 必須是陣列`);
|
||||||
|
for (const diagram of diagrams) {
|
||||||
|
if (!DIAGRAM_KINDS.has(diagram?.kind)) fail('DIAGRAM_UNSUPPORTED', `${owner} 有未知圖表類型`);
|
||||||
|
if (!Array.isArray(diagram.nodes) || !Array.isArray(diagram.edges)) fail('SCHEMA_INVALID', `${owner} 圖表必須有 nodes 與 edges`);
|
||||||
|
const ids = new Set(diagram.nodes.map((node) => node.id));
|
||||||
|
for (const edge of diagram.edges) if (!ids.has(edge.from) || !ids.has(edge.to)) fail('SCHEMA_INVALID', `${owner} 圖表有不存在的邊端點`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOverview(document) {
|
||||||
|
validateOverview(document);
|
||||||
|
const requirement = document.requirement;
|
||||||
|
const packages = document.workPackages;
|
||||||
|
const diagrams = [...(requirement.diagrams ?? []), ...packages.flatMap((workPackage) => workPackage.diagrams ?? [])];
|
||||||
|
const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(requirement.title ?? '需求總覽')}</title><style>${styles()}</style></head><body><main><header><h1>${escapeHtml(requirement.title ?? '需求總覽')}</h1><p class="meta">來源:${escapeHtml(document.source.requirement.repo)}#${document.source.requirement.index}</p></header><p class="lede">${escapeHtml(requirement.summary ?? '')}</p>${renderListSection('目標', requirement.goals, 'goals')}${renderListSection('非目標', requirement.nonGoals, 'non-goals')}${renderDiagrams(diagrams)}<section id="work-packages"><h2>工作包</h2>${packages.map(renderWorkPackage).join('')}</section>${renderFormulas(requirement.formulas)}<footer>schemaVersion ${document.schemaVersion} · 產生時間 ${escapeHtml(document.overview?.generatedAt ?? '')}</footer></main></body></html>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
export function renderFallbackSvg(document) {
|
||||||
|
validateOverview(document);
|
||||||
|
const diagrams = [...(document.requirement.diagrams ?? []), ...document.workPackages.flatMap((workPackage) => workPackage.diagrams ?? [])];
|
||||||
|
const height = 180 + diagrams.length * 300;
|
||||||
|
const diagramMarkup = diagrams.map((diagram, index) => `<g transform="translate(40 ${150 + index * 300})">${renderSvgContents(diagram)}</g>`).join('');
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="${height}" viewBox="0 0 1200 ${height}"><rect width="100%" height="100%" fill="#ffffff"/><text x="40" y="55" font-size="30" font-family="sans-serif">${escapeHtml(document.requirement.title ?? '需求總覽')}</text><text x="40" y="95" font-size="18" font-family="sans-serif">${escapeHtml(document.requirement.summary ?? '')}</text>${diagramMarkup}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkPackage(workPackage, index) {
|
||||||
|
const slug = slugify(workPackage.title) || `work-package-${index + 1}`;
|
||||||
|
const done = workPackage.todos.filter((todo) => todo.done).length;
|
||||||
|
return `<article id="work-package-${slug}"><h3>${escapeHtml(workPackage.title)}</h3><p>${escapeHtml(workPackage.description)}</p><dl><dt>類型</dt><dd>${escapeHtml(workPackage.type)}</dd><dt>進度</dt><dd>${done}/${workPackage.todos.length} 項待辦</dd><dt>依賴</dt><dd>${escapeHtml(workPackage.depends.join(', ') || '無')}</dd><dt>repo</dt><dd>${escapeHtml(workPackage.repos.join(', ') || '無')}</dd></dl><p><a href="${escapeAttr(workPackage.issue)}">查看工作包詳細內容</a></p></article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiagrams(diagrams) {
|
||||||
|
return diagrams.map((diagram, index) => `<section id="diagram-${index + 1}"><h2>${escapeHtml(diagram.title ?? diagram.kind)}</h2><div class="diagram">${renderSvg(diagram)}</div>${diagram.omitted ? `<p class="omitted">未產生:${escapeHtml(diagram.omitted)}</p>` : ''}</section>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSvg(diagram) {
|
||||||
|
const columns = Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length || 1)));
|
||||||
|
const width = Math.max(640, columns * 220);
|
||||||
|
const height = Math.max(180, Math.ceil((diagram.nodes.length || 1) / columns) * 100 + 80);
|
||||||
|
const positions = new Map(diagram.nodes.map((node, index) => [node.id, { x: 30 + (index % columns) * 210, y: 35 + Math.floor(index / columns) * 100 }]));
|
||||||
|
const edges = diagram.edges.map((edge) => { const from = positions.get(edge.from); const to = positions.get(edge.to); return from && to ? `<line x1="${from.x + 150}" y1="${from.y + 24}" x2="${to.x}" y2="${to.y + 24}" marker-end="url(#arrow)"/><text x="${(from.x + to.x + 150) / 2}" y="${(from.y + to.y) / 2 + 18}">${escapeHtml(edge.label ?? '')}</text>` : ''; }).join('');
|
||||||
|
const nodes = diagram.nodes.map((node) => { const point = positions.get(node.id); return `<g><rect x="${point.x}" y="${point.y}" width="150" height="48" rx="8"/><text x="${point.x + 75}" y="${point.y + 29}">${escapeHtml(node.label ?? node.id)}</text></g>`; }).join('');
|
||||||
|
return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(diagram.title ?? diagram.kind)}"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"/></marker></defs>${edges}${nodes}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSvgContents(diagram) {
|
||||||
|
return renderSvg(diagram).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
|
||||||
|
}
|
||||||
|
function renderListSection(title, values, id) { if (!Array.isArray(values) || values.length === 0) return ''; return `<section id="${id}"><h2>${title}</h2><ul>${values.map((value) => `<li>${escapeHtml(typeof value === 'string' ? value : value.text ?? '')}</li>`).join('')}</ul></section>`; }
|
||||||
|
|
||||||
|
function renderFormulas(formulas) {
|
||||||
|
if (!Array.isArray(formulas) || formulas.length === 0) return '';
|
||||||
|
return `<section id="formulas"><h2>公式</h2>${formulas.map((formula) => {
|
||||||
|
const latex = String(formula.latex ?? '');
|
||||||
|
const supported = /^[A-Za-z0-9\s+\-*/=().,_^{}]+$/.test(latex);
|
||||||
|
return `<article class="formula"><h3>${escapeHtml(formula.title ?? '公式')}</h3><p>${escapeHtml(formula.description ?? '')}</p>${supported ? `<svg class="formula-svg" viewBox="0 0 700 60" role="img" aria-label="${escapeAttr(latex)}"><text x="12" y="38">${escapeHtml(latex)}</text></svg>` : `<p class="omitted">公式未渲染,原文如下:</p><pre>${escapeHtml(latex)}</pre>`}</article>`;
|
||||||
|
}).join('')}</section>`;
|
||||||
|
}
|
||||||
|
function styles() { return `:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;--line:#d1d9e0;--surface:#f6f8fa;--accent:#0969da}*{box-sizing:border-box}body{margin:0;padding:48px 16px 96px;background:var(--bg);color:var(--fg);font:16px/1.7 -apple-system,"Noto Sans TC","Microsoft JhengHei",sans-serif}main{max-width:980px;margin:0 auto}header{border-bottom:1px solid var(--line);padding-bottom:24px;margin-bottom:40px}h1{font-size:30px;line-height:1.3}h2{font-size:15px;letter-spacing:.08em;color:var(--muted);margin:32px 0 16px}h3{line-height:1.4}.meta,.omitted{color:var(--muted)}.lede{font-size:21px;padding:20px 24px;background:var(--surface);border-left:3px solid var(--accent)}ul{padding-left:24px}.diagram{padding:20px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:auto}.diagram svg{display:block;min-width:620px;height:auto}.diagram rect{fill:var(--bg);stroke:var(--accent);stroke-width:2}.diagram text{fill:var(--fg);font-size:14px;text-anchor:middle}.diagram line{stroke:var(--accent);stroke-width:2}.diagram marker path{fill:var(--accent)}article{border-top:1px solid var(--line);padding:16px 0}dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;color:var(--muted)}dt{font-weight:600}dd{margin:0}a{color:var(--accent)}pre{white-space:pre-wrap;background:var(--surface);padding:12px;border-radius:6px}footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--line:#3d444d;--surface:#151b23;--accent:#4493f8}}`; }
|
||||||
|
function slugify(value) { return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
|
||||||
|
function escapeHtml(value) { return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
||||||
|
const escapeAttr = escapeHtml;
|
||||||
|
function fail(code, message) { throw new ScriptError(code, message); }
|
||||||
|
|
||||||
|
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
||||||
|
main(async () => {
|
||||||
|
const flags = parseFlags(process.argv.slice(2), { required: ['input', 'output'], optional: ['manifest', 'svg'] });
|
||||||
|
const inputPath = resolve(flags.input);
|
||||||
|
const outputPath = resolve(flags.output);
|
||||||
|
const document = JSON.parse(readFileSync(inputPath, 'utf8'));
|
||||||
|
validateOverview(document);
|
||||||
|
mkdirSync(dirname(outputPath), { recursive: true });
|
||||||
|
writeFileSync(outputPath, renderOverview(document));
|
||||||
|
const result = { schemaVersion: SCHEMA_VERSION, html: outputPath, sha256: createHash('sha256').update(readFileSync(outputPath)).digest('hex') };
|
||||||
|
if (flags.svg) {
|
||||||
|
const svgPath = resolve(flags.svg);
|
||||||
|
mkdirSync(dirname(svgPath), { recursive: true });
|
||||||
|
writeFileSync(svgPath, renderFallbackSvg(document));
|
||||||
|
result.svg = svgPath;
|
||||||
|
}
|
||||||
|
if (flags.manifest) { mkdirSync(dirname(resolve(flags.manifest)), { recursive: true }); writeFileSync(resolve(flags.manifest), `${JSON.stringify({ ...result, screenshots: [] }, null, 2)}\n`); }
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-Hant">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{{標題}}</title>
|
|
||||||
<style>
|
|
||||||
/* 樣式全部集中在這裡,內文只放佔位——改版面不必動內容,換內容不必碰樣式 */
|
|
||||||
:root {
|
|
||||||
--bg: #ffffff;
|
|
||||||
--fg: #1f2328;
|
|
||||||
--muted: #59636e;
|
|
||||||
--line: #d1d9e0;
|
|
||||||
--accent: #0969da;
|
|
||||||
--surface: #f6f8fa;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root:not([data-theme="light"]) {
|
|
||||||
--bg: #0d1117;
|
|
||||||
--fg: #e6edf3;
|
|
||||||
--muted: #9198a1;
|
|
||||||
--line: #3d444d;
|
|
||||||
--accent: #4493f8;
|
|
||||||
--surface: #151b23;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
--bg: #0d1117;
|
|
||||||
--fg: #e6edf3;
|
|
||||||
--muted: #9198a1;
|
|
||||||
--line: #3d444d;
|
|
||||||
--accent: #4493f8;
|
|
||||||
--surface: #151b23;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 48px 16px 96px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
font: 16px/1.7 -apple-system, "Noto Sans TC", "Microsoft JhengHei", sans-serif;
|
|
||||||
}
|
|
||||||
main { max-width: 900px; margin: 0 auto; }
|
|
||||||
|
|
||||||
header { border-bottom: 1px solid var(--line); padding-bottom: 24px; margin-bottom: 40px; }
|
|
||||||
h1 { font-size: 30px; line-height: 1.3; margin: 0 0 12px; }
|
|
||||||
.meta { color: var(--muted); font-size: 14px; }
|
|
||||||
.meta a { color: var(--accent); text-decoration: none; }
|
|
||||||
.meta a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
font-size: 21px;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0 0 40px;
|
|
||||||
padding: 20px 24px;
|
|
||||||
background: var(--surface);
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
section { margin-bottom: 40px; }
|
|
||||||
h2 { font-size: 15px; letter-spacing: .08em; text-transform: uppercase;
|
|
||||||
color: var(--muted); margin: 0 0 16px; font-weight: 600; }
|
|
||||||
ul { margin: 0; padding-left: 22px; }
|
|
||||||
li { margin-bottom: 8px; }
|
|
||||||
|
|
||||||
figure { margin: 0; padding: 24px; background: var(--surface);
|
|
||||||
border: 1px solid var(--line); border-radius: 8px; overflow-x: auto; }
|
|
||||||
figure .mermaid { display: flex; justify-content: center; }
|
|
||||||
|
|
||||||
footer { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--line);
|
|
||||||
color: var(--muted); font-size: 13px; }
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
body { padding: 32px 16px 64px; }
|
|
||||||
h1 { font-size: 24px; }
|
|
||||||
.lede { font-size: 18px; padding: 16px 18px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<header>
|
|
||||||
<h1>{{標題}}</h1>
|
|
||||||
<p class="meta">{{來源議題}}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p class="lede">{{總覽}}</p>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>目標</h2>
|
|
||||||
<ul>{{目標}}</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>流程</h2>
|
|
||||||
<figure><div class="mermaid">{{流程圖}}</div></figure>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{工作包全景}}
|
|
||||||
|
|
||||||
<footer>{{頁尾}}</footer>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
||||||
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
mermaid.initialize({ startOnLoad: true, theme: dark ? 'dark' : 'default' });
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+43
-119
@@ -1,128 +1,52 @@
|
|||||||
/**
|
|
||||||
* 圖解版總覽網頁:模板本身,以及兩份正本裡產生它、把網址寫回議題的規則。
|
|
||||||
*/
|
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { promptStep, readPrompt, readTemplateFile } from './helpers/prompt-doc.js';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { renderOverview, validateOverview } from '../scripts/overview-render.js';
|
||||||
|
import { repoRoot } from './helpers/run-script.js';
|
||||||
|
|
||||||
const template = readTemplateFile('overview-artifact.html');
|
const base = {
|
||||||
const planPrompt = readPrompt('sdlc-plan');
|
schemaVersion: 1,
|
||||||
const analyzePrompt = readPrompt('sdlc-analyze');
|
source: { requirement: { repo: 'owner/repo', index: 7 } },
|
||||||
/** 只看產生總覽那一步,避免拿整份正本的任何一處來充數 */
|
requirement: {
|
||||||
const planStep = promptStep(planPrompt, '產生圖解版總覽');
|
title: '建立需求總覽',
|
||||||
/** 分析版的那一步,同樣只看它自己 */
|
summary: '把複雜需求整理成可理解的交接資料。',
|
||||||
const analyzeStep = promptStep(analyzePrompt, '產生分析版的圖解總覽');
|
goals: ['可追蹤工作包', '可離線檢視'],
|
||||||
|
nonGoals: [],
|
||||||
|
diagrams: [{ kind: 'flowchart', direction: 'LR', title: '輸入流程', nodes: [{ id: 'a', label: '輸入' }, { id: 'b', label: '輸出' }], edges: [{ from: 'a', to: 'b', label: '轉換' }] }],
|
||||||
|
formulas: [{ title: '估算', latex: 'a+b', description: '簡單公式' }],
|
||||||
|
},
|
||||||
|
workPackages: [{
|
||||||
|
title: '建立抽取契約',
|
||||||
|
type: 'implementation',
|
||||||
|
description: '提供可重複的資料抽取。',
|
||||||
|
scope: '只處理抽取。',
|
||||||
|
repos: ['owner/repo'],
|
||||||
|
depends: [],
|
||||||
|
todos: [{ text: '建立函式', done: true }],
|
||||||
|
acceptance: ['輸出固定'],
|
||||||
|
issue: 'https://gitea.example/owner/repo/issues/8',
|
||||||
|
handoff: false,
|
||||||
|
}],
|
||||||
|
overview: { generatedAt: '2026-09-18T00:00:00Z' },
|
||||||
|
};
|
||||||
|
|
||||||
/** 模板要填的欄位 */
|
test('renderer validates and renders self-contained HTML', () => {
|
||||||
const PLACEHOLDERS = ['標題', '來源議題', '總覽', '目標', '流程圖', '工作包全景', '頁尾'];
|
const html = renderOverview(base);
|
||||||
|
assert.match(html, /<!doctype html>/i);
|
||||||
// ── 模板 ───────────────────────────────────────────────────────────
|
assert.match(html, /<svg/);
|
||||||
|
assert.match(html, /建立抽取契約/);
|
||||||
test('模板以 {{變數}} 佔位,欄位齊全', () => {
|
assert.match(html, /a\+b/);
|
||||||
const found = new Set([...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]));
|
assert.doesNotMatch(html, /mermaid/);
|
||||||
for (const name of PLACEHOLDERS) {
|
assert.doesNotMatch(html, /cdn\.jsdelivr/);
|
||||||
assert.ok(found.has(name), `模板缺少佔位 {{${name}}}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('樣式與內容分離:樣式集中在 style 區塊,內文不帶 style 屬性', () => {
|
test('renderer rejects unsupported schema versions and graph kinds', () => {
|
||||||
const styleBlocks = template.match(/<style>[\s\S]*?<\/style>/g) ?? [];
|
assert.throws(() => validateOverview({ ...base, schemaVersion: 2 }), (error) => error.code === 'SCHEMA_UNSUPPORTED');
|
||||||
assert.equal(styleBlocks.length, 1, '樣式應集中在單一 style 區塊');
|
assert.throws(() => validateOverview({ ...base, requirement: { ...base.requirement, diagrams: [{ kind: 'unknown', nodes: [], edges: [] }] } }), (error) => error.code === 'DIAGRAM_UNSUPPORTED');
|
||||||
|
|
||||||
const body = template.slice(template.indexOf('<body>'));
|
|
||||||
assert.equal(/\sstyle="/.test(body), false, '內文不該出現行內樣式');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('是一份可以直接開的完整 HTML', () => {
|
test('renderer script is shipped and template is intentionally removed', () => {
|
||||||
assert.match(template, /^<!DOCTYPE html>/);
|
assert.equal(readFileSync(join(repoRoot, 'scripts', 'overview-render.js'), 'utf8').includes('overview'), true);
|
||||||
assert.match(template, /<html lang="zh-Hant">/);
|
assert.throws(() => readFileSync(join(repoRoot, 'templates', 'overview-artifact.html'), 'utf8'));
|
||||||
assert.match(template, /<meta charset="utf-8">/);
|
|
||||||
assert.match(template, /<meta name="viewport"/, '要能在投影與手機上都看得清楚');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('深色模式不靠手動切換也能用', () => {
|
|
||||||
assert.match(template, /prefers-color-scheme: dark/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mermaid 圖有被實際渲染,不是把原始碼丟給讀者看', () => {
|
|
||||||
// 只比對「有出現 mermaid 字樣」會被 CDN 網址矇混過去,要驗到真的有初始化
|
|
||||||
const script = template.match(/<script[\s\S]*?<\/script>/)?.[0] ?? '';
|
|
||||||
assert.match(script, /mermaid\.initialize\(/);
|
|
||||||
assert.match(template, /class="mermaid"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('模板裡唯一的腳本就是畫圖那一段,沒有夾帶其他邏輯', () => {
|
|
||||||
// AGENTS.md 說模板不含邏輯,這份是唯一的例外,例外要維持在最小範圍
|
|
||||||
const scripts = template.match(/<script[\s\S]*?<\/script>/g) ?? [];
|
|
||||||
assert.equal(scripts.length, 1);
|
|
||||||
assert.equal(/\bfetch\(|localStorage|document\.cookie|XMLHttpRequest/.test(scripts[0]), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('工作包全景是整段佔位,規劃階段才填得了空字串', () => {
|
|
||||||
// 規劃階段沒有工作包,整段要能消失,所以佔位不可以被包在寫死的 section 裡
|
|
||||||
const line = template.split('\n').find((l) => l.includes('{{工作包全景}}'));
|
|
||||||
assert.equal(line.trim(), '{{工作包全景}}');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── sdlc-plan 的步驟 ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('規劃正本交代了產生總覽與寫回網址', () => {
|
|
||||||
assert.match(planStep, /templates\/overview-artifact\.html/);
|
|
||||||
assert.match(planStep, /--overview-url/);
|
|
||||||
assert.match(planStep, /issue-update/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('規劃正本說明流程圖填進模板時不帶圍欄', () => {
|
|
||||||
assert.match(planStep, /不\*\*含\*\*圍欄|\*\*不含\*\*圍欄/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('規劃階段沒有工作包,正本要說清楚全景填空字串', () => {
|
|
||||||
assert.match(planStep, /\{\{工作包全景\}\}/);
|
|
||||||
assert.match(planStep, /填空字串/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('正本說明這份網頁是給非技術的人看的', () => {
|
|
||||||
assert.match(planStep, /非技術/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── sdlc-analyze 的步驟 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('分析正本的全景圖用 graph TD,並畫出相依與時程', () => {
|
|
||||||
const step = analyzeStep;
|
|
||||||
assert.match(step, /graph TD/);
|
|
||||||
assert.match(step, /相依/);
|
|
||||||
assert.match(step, /截止日/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('分析正本沿用同一份模板,不另立一份', () => {
|
|
||||||
assert.match(analyzePrompt, /同一份 `templates\/overview-artifact\.html`/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('全景圖一樣有節點上限,超過時的做法有交代', () => {
|
|
||||||
const step = analyzeStep;
|
|
||||||
assert.match(step, /12/);
|
|
||||||
assert.match(step, /最長路徑/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('分析正本說明重跑會就地更新,不會留下兩個連結', () => {
|
|
||||||
const step = analyzeStep;
|
|
||||||
assert.match(step, /就地更新/);
|
|
||||||
assert.match(step, /只掛一個總覽網址/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 兩份共通 ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('兩份正本都指名同一支腳本寫回網址', () => {
|
|
||||||
for (const [name, prompt] of [['sdlc-plan', planPrompt], ['sdlc-analyze', analyzePrompt]]) {
|
|
||||||
assert.match(prompt, /--overview-url/, `${name} 沒有指名寫回網址的方式`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('規劃正本說明連結會自動附上私有提醒', () => {
|
|
||||||
assert.match(planStep, /私有/);
|
|
||||||
assert.match(planStep, /組織外/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('規劃正本強調議題原本的白話總覽不被取代', () => {
|
|
||||||
assert.match(planStep, /網頁是補充,不是取代/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { runScript, repoRoot, tmpRoot } from './helpers/run-script.js';
|
||||||
|
|
||||||
|
const input = join(tmpRoot, 'capture-input.json');
|
||||||
|
const html = join(tmpRoot, 'capture.html');
|
||||||
|
const svg = join(tmpRoot, 'capture.svg');
|
||||||
|
const manifest = join(tmpRoot, 'capture-manifest.json');
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
source: { requirement: { repo: 'owner/repo', index: 7 } },
|
||||||
|
requirement: { title: 'Capture', summary: 'Fallback', goals: [], nonGoals: [], diagrams: [{ kind: 'flowchart', nodes: [{ id: 'a', label: 'A' }], edges: [] }] },
|
||||||
|
workPackages: [],
|
||||||
|
overview: { generatedAt: '2026-09-18T00:00:00Z' },
|
||||||
|
};
|
||||||
|
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
|
writeFileSync(input, `${JSON.stringify(document)}\n`);
|
||||||
|
|
||||||
|
test('renderer emits SVG fallback alongside HTML', async () => {
|
||||||
|
const result = await runScript('overview-render.js', ['--input', input, '--output', html, '--svg', svg, '--manifest', manifest]);
|
||||||
|
assert.equal(result.code, 0, JSON.stringify(result.json));
|
||||||
|
assert.equal(existsSync(svg), true);
|
||||||
|
assert.match(readFileSync(svg, 'utf8'), /^<svg/);
|
||||||
|
assert.equal(result.json.data.svg, svg);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capture reports missing backend instead of claiming success', async () => {
|
||||||
|
const result = await runScript('overview-capture.js', ['--backend', 'svg', '--svg', svg, '--output', join(tmpRoot, 'capture.png')]);
|
||||||
|
if (result.code === 0) assert.equal(result.json.data.backend, 'svg');
|
||||||
|
else assert.equal(result.json.error.code, 'BINARY_NOT_FOUND');
|
||||||
|
});
|
||||||
@@ -73,11 +73,11 @@ test('正本明令缺漏資訊要逐項問,不得自行編造', () => {
|
|||||||
assert.match(prompt, /不得(自行|替使用者)?(編造|填入)/);
|
assert.match(prompt, /不得(自行|替使用者)?(編造|填入)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('正本釘住 Mermaid flowchart 的節點上限與字數上限', () => {
|
test('正本釘住抽象圖表的節點上限與字數上限', () => {
|
||||||
assert.match(prompt, /flowchart/);
|
assert.match(prompt, /抽象節點與邊|抽象/);
|
||||||
assert.match(prompt, /12/);
|
assert.match(prompt, /12/);
|
||||||
assert.match(prompt, /8\s*字/);
|
assert.match(prompt, /8\s*字/);
|
||||||
assert.match(prompt, /拆(成多)?圖|不畫/);
|
assert.match(prompt, /拆圖|omitted/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('正本要求標籤只能從既有標籤挑,並指名用 labels-list 取得', () => {
|
test('正本要求標籤只能從既有標籤挑,並指名用 labels-list 取得', () => {
|
||||||
@@ -95,13 +95,10 @@ test('正本逐一交代九個段落,且順序與模板一致', () => {
|
|||||||
assertPromptListsSections(prompt, SECTIONS);
|
assertPromptListsSections(prompt, SECTIONS);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
|
test('正本交代圖表由 renderer 重新產生', () => {
|
||||||
assertDiagramPlaceholderOnly(template, '流程圖', '驗收標準');
|
assert.match(prompt, /overview-render\.js/);
|
||||||
});
|
assert.match(prompt, /重新產生 SVG/);
|
||||||
|
assert.match(prompt, /產生 Mermaid/);
|
||||||
test('正本交代了畫與不畫兩種情況各該填什麼', () => {
|
|
||||||
assert.match(prompt, /```mermaid/);
|
|
||||||
assert.match(prompt, /不要加圍欄|不加圍欄/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 計時 ───────────────────────────────────────────────────────────
|
// ── 計時 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -99,14 +99,11 @@ test('關聯段落必須指回來源需求議題', () => {
|
|||||||
assert.match(phase2, /需求議題:#/);
|
assert.match(phase2, /需求議題:#/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('架構圖依性質三選一,並釘住節點與字數上限', () => {
|
test('架構圖使用抽象資料並釘住節點與字數上限', () => {
|
||||||
for (const kind of ['sequenceDiagram', 'flowchart', 'stateDiagram-v2']) {
|
assert.match(prompt, /抽象.*kind|抽象.*nodes/);
|
||||||
assert.match(prompt, new RegExp(kind));
|
assert.match(prompt, /12/);
|
||||||
}
|
assert.match(prompt, /8\s*字/);
|
||||||
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
|
assert.match(prompt, /拆圖|omitted/);
|
||||||
assert.match(limits, /12/);
|
|
||||||
assert.match(limits, /8\s*字/);
|
|
||||||
assert.match(limits, /拆成多張圖|不畫/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('寫入前先試跑,且試跑的價值有被說明', () => {
|
test('寫入前先試跑,且試跑的價值有被說明', () => {
|
||||||
@@ -132,7 +129,7 @@ test('第二段明列它「不做」的事,避免搶走後續流程的工作',
|
|||||||
assert.match(boundary, /不寫人天估算/);
|
assert.match(boundary, /不寫人天估算/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('「不畫圖」是有條件的退路,不是免死金牌', () => {
|
test('缺少圖表資料時必須留下可追蹤原因', () => {
|
||||||
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
|
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
|
||||||
assert.match(limits, /只在超過上限拆不開、或畫了不會比文字更清楚時/);
|
assert.match(limits, /拆圖|omitted/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user