Merge pull request 'feat/work-package-issues/main' (#24) from feat/work-package-issues/main into master
Reviewed-on: #24 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #24.
This commit is contained in:
+85
-6
@@ -1,11 +1,12 @@
|
||||
name: sdlc-analyze
|
||||
description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,把疑點逐題問到共識並輸出摘要。
|
||||
description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,逐題問到共識後產生工作包議題。
|
||||
|
||||
# sdlc-analyze
|
||||
|
||||
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識。
|
||||
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識,再把共識變成一批工作包議題。
|
||||
|
||||
這一段到共識摘要為止,**不寫入 Gitea**。把工作包開出去是下一段的事。
|
||||
分成兩段:**可行性分析**到共識摘要為止,完全不寫入 Gitea;使用者看過摘要點頭之後,
|
||||
才進入**產生工作包**,那一段才會建立議題。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
@@ -13,7 +14,7 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可
|
||||
|
||||
一個需求議題編號。
|
||||
|
||||
## 步驟
|
||||
## 第一段:可行性分析
|
||||
|
||||
### 1. 讀議題
|
||||
|
||||
@@ -66,9 +67,87 @@ node scripts/issue-extract.js --repo <owner/name> --index <編號>
|
||||
|
||||
摘要只印在終端,**不寫回議題、不建立任何東西**。使用者看過點頭之後,才進入下一段。
|
||||
|
||||
## 第二段:產生工作包
|
||||
|
||||
**使用者對共識摘要點頭之後才開始。** 摘要沒有經過確認就不要往下走。
|
||||
|
||||
### 5. 切出工作包
|
||||
|
||||
把需求切成幾顆工作包。一顆工作包是**開發者拿了就能動手、做完有明確結果**的單位:
|
||||
它有自己的驗收標準,做完能單獨被檢視,不必等別的工作包一起才看得出成果。
|
||||
|
||||
切的依據是第一段問出來的共識,特別是時程清單那份暫定拆法——那本來就是這一段的草稿。
|
||||
|
||||
**標題格式為「{動詞}{名詞}」**,例如「建立工作包的抽取契約」、「產生圖解版總覽網頁」。
|
||||
**禁止流水編號與任何無意義代號**(`WP-01`、`任務三`、`第一階段`):命名本身就要說明用途,
|
||||
看標題就知道這顆在做什麼,不必點進去。
|
||||
|
||||
### 6. 組出每顆工作包的內容
|
||||
|
||||
套用 `templates/work-package-issue.md`,依序填滿九個段落:
|
||||
|
||||
1. **這個工作包在做什麼** — 一句話。讓人掃過標題與這一行就決定要不要點進來。
|
||||
2. **描述** — 從使用者的角度說這顆做完之後什麼事變得可能,不要寫成逐層的實作清單。
|
||||
3. **架構圖** — 見下方「架構圖的限制」。
|
||||
4. **範圍邊界** — 明列**不做什麼**。這一段的用途是抵抗範圍蔓延,寫得越具體越有用。
|
||||
5. **介面契約** — 表格,四欄:介面/產出者/消費者/形狀。讓人知道自己產出的東西誰會消費。
|
||||
這顆不產出對外介面就寫一列「無」,不要留空表。
|
||||
6. **待辦** — 巢狀結構:每一項待辦底下掛**它自己的**驗收標準,讓人知道這一項做到什麼程度算完成。
|
||||
|
||||
```
|
||||
- [ ] 建立共用函式庫
|
||||
- [ ] 具名 flag 解析可拒絕未知參數
|
||||
- [ ] 單行 JSON 輸出格式固定
|
||||
- [ ] 加上前置檢查
|
||||
- [ ] 四層各自回傳可區分的錯誤碼
|
||||
```
|
||||
|
||||
上層是待辦、縮排一層是該項的驗收,不要再往下巢狀。兩者都用 checkbox,實作時會被逐項勾選。
|
||||
7. **整體驗收** — 整顆工作包做完才驗得出來的事,與個別待辦的驗收不重複。
|
||||
8. **repo 列表** — 這顆會動到哪些 repo。
|
||||
9. **關聯** — 至少要有一行 `需求議題:#<編號>` 指回來源。阻擋、先決與人天估算由後續流程補上。
|
||||
|
||||
### 7. 先試跑,再寫入
|
||||
|
||||
每顆工作包各寫一個暫存檔,然後逐顆:
|
||||
|
||||
```
|
||||
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> \
|
||||
--labels "<標籤>" --dry-run
|
||||
```
|
||||
|
||||
`--dry-run` 會印出將送出的請求、把標籤名稱換成 id,並在標題已存在時如實顯示「實跑會是
|
||||
no-op」。確認無誤後拿掉該旗標再跑一次。
|
||||
|
||||
標籤一樣只能從 `scripts/labels-list.js` 回傳的既有標籤裡挑,**不得自行建立新標籤**。
|
||||
|
||||
中斷後重跑不會產生重複工作包:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆
|
||||
並把 `created` 設為 `false`。
|
||||
|
||||
### 8. 回報
|
||||
|
||||
列出每顆工作包的編號、標題與網址。不要把議題內容再貼一次。
|
||||
|
||||
## 架構圖的限制
|
||||
|
||||
依工作包的性質選圖:
|
||||
|
||||
- **`sequenceDiagram`** — 重點在「誰呼叫誰、順序為何」時用。
|
||||
- **`flowchart`** — 重點在「條件分支與資料流向」時用。
|
||||
- **`stateDiagram-v2`** — 重點在「狀態怎麼轉移」時用。
|
||||
|
||||
節點數上限 **12**,每個節點的文字上限 **8 字**。超過就拆成多張圖,或者乾脆不畫。
|
||||
|
||||
模板的 `{{架構圖}}` 要填入**完整的內容**,兩種形式擇一:
|
||||
|
||||
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
|
||||
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫,**不要加圍欄**。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 不對 Gitea 產生任何寫入:不建議題、不改描述、不貼標籤、不留留言。
|
||||
- **共識摘要之前不對 Gitea 產生任何寫入**:不建議題、不改描述、不貼標籤、不留留言。
|
||||
- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是後續流程的事。
|
||||
- 不自行建立標籤、Milestone 或專案看板。
|
||||
- 不修改使用者的專案檔案。查證既有功能時只讀不寫。
|
||||
- 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。
|
||||
- 不自行建立標籤、Milestone 或專案看板。
|
||||
- 不關閉或刪除任何既有議題。
|
||||
|
||||
@@ -84,7 +84,7 @@ node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file
|
||||
模板的 `{{流程圖}}` 要填入**完整的內容**,兩種形式擇一:
|
||||
|
||||
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
|
||||
- 不畫:一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。
|
||||
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。
|
||||
|
||||
圍欄寫在填入的內容裡而不是模板裡,否則不畫圖時會留下一個空的 mermaid 區塊,
|
||||
在議題頁上是一塊渲染失敗的紅字。
|
||||
|
||||
@@ -102,6 +102,10 @@ export function listSection(sections, name) {
|
||||
/**
|
||||
* 取出兩欄表格型段落。以分隔列(|---|---|)為界,之後才是資料列;
|
||||
* 沒有分隔列就當成沒有資料,避免把表頭當成一筆名詞。
|
||||
*
|
||||
* 只處理兩欄:多出來的欄會被丟掉。目前唯一的使用者是需求議題的領域名詞表。
|
||||
* 工作包的介面契約是四欄(介面/產出者/消費者/形狀),wp-extract 需要另一個
|
||||
* 保留全部欄位的版本,不能直接沿用這一支。
|
||||
* @param {Map<string, string>} sections
|
||||
* @param {string} name
|
||||
* @returns {{term: string, def: string}[]}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
## 這個工作包在做什麼
|
||||
|
||||
{{一句話}}
|
||||
|
||||
## 描述
|
||||
|
||||
{{描述}}
|
||||
|
||||
## 架構圖
|
||||
|
||||
{{架構圖}}
|
||||
|
||||
## 範圍邊界
|
||||
|
||||
{{範圍邊界}}
|
||||
|
||||
## 介面契約
|
||||
|
||||
| 介面 | 產出者 | 消費者 | 形狀 |
|
||||
| --- | --- | --- | --- |
|
||||
{{介面契約}}
|
||||
|
||||
## 待辦
|
||||
|
||||
{{待辦}}
|
||||
|
||||
## 整體驗收
|
||||
|
||||
{{整體驗收}}
|
||||
|
||||
## repo 列表
|
||||
|
||||
{{repo列表}}
|
||||
|
||||
## 關聯
|
||||
|
||||
{{關聯}}
|
||||
@@ -60,3 +60,33 @@ export function assertNeutralPrompt(prompt, command) {
|
||||
assert.equal(prompt.includes(token), false, `正本不該出現平台專屬字樣:${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 斷言模板的 `## 標題` 就是這組段落,順序一致。
|
||||
* 段落順序即下游抽取契約的解析依據,兩邊必須一起改。
|
||||
*/
|
||||
export function assertTemplateSections(template, sections) {
|
||||
const headings = [...template.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
|
||||
assert.deepEqual(headings, sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* 斷言正本的編號清單逐一交代了這組段落,順序與模板一致。
|
||||
* @param {string} text 正本內容,或其中相關的一段
|
||||
*/
|
||||
export function assertPromptListsSections(text, sections) {
|
||||
const listed = [...text.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)].map((m) => m[1].trim());
|
||||
assert.deepEqual(listed, sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* 斷言圖表段落只有佔位、沒有寫死的 mermaid 圍欄。
|
||||
* 正本允許「乾脆不畫」,圍欄寫死在模板裡的話,不畫時會在議題頁留下一塊渲染失敗的空區塊。
|
||||
* @param {string} heading 圖表段落的標題,例如 '流程圖'
|
||||
* @param {string} nextHeading 其後一個段落的標題,用來框出範圍
|
||||
*/
|
||||
export function assertDiagramPlaceholderOnly(template, heading, nextHeading) {
|
||||
assert.equal(template.includes('```mermaid'), false);
|
||||
const section = template.slice(template.indexOf(`## ${heading}`), template.indexOf(`## ${nextHeading}`));
|
||||
assert.match(section.trim(), new RegExp(`^## ${heading}\\s+\\{\\{${heading}\\}\\}$`));
|
||||
}
|
||||
|
||||
@@ -76,13 +76,20 @@ test('正本規定最後輸出共識摘要,且摘要只印不寫', () => {
|
||||
assert.match(prompt, /不寫回議題/);
|
||||
});
|
||||
|
||||
test('正本把「不對 Gitea 寫入」寫成明確邊界', () => {
|
||||
test('正本把「共識摘要之前不寫入」寫成明確邊界', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不對 Gitea 產生任何寫入/);
|
||||
assert.match(boundary, /共識摘要之前不對 Gitea 產生任何寫入/);
|
||||
assert.match(boundary, /不建議題/);
|
||||
assert.match(boundary, /不留留言/);
|
||||
});
|
||||
|
||||
test('第一段在共識摘要之前不叫用任何寫入型腳本', () => {
|
||||
const analysis = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段'));
|
||||
for (const writer of ['issue-create', 'issue-update', 'issue-link', 'project-add', 'timer']) {
|
||||
assert.equal(analysis.includes(writer), false, `第一段不該出現寫入型腳本 ${writer}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('正本要求先看未處理留言數,不是 0 就提示先整併', () => {
|
||||
assert.match(prompt, /未處理留言數/);
|
||||
assert.match(prompt, /sdlc-sync/);
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertNeutralPrompt, readPrompt, readTemplate } from './helpers/prompt-doc.js';
|
||||
import {
|
||||
assertDiagramPlaceholderOnly,
|
||||
assertNeutralPrompt,
|
||||
assertPromptListsSections,
|
||||
assertTemplateSections,
|
||||
readPrompt,
|
||||
readTemplate,
|
||||
} from './helpers/prompt-doc.js';
|
||||
|
||||
const template = readTemplate('requirement-issue');
|
||||
const prompt = readPrompt('sdlc-plan');
|
||||
@@ -28,8 +35,7 @@ const SECTIONS = [
|
||||
// ── 輸出模板 ───────────────────────────────────────────────────────
|
||||
|
||||
test('模板依序包含九個段落', () => {
|
||||
const headings = [...template.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
|
||||
assert.deepEqual(headings, SECTIONS);
|
||||
assertTemplateSections(template, SECTIONS);
|
||||
});
|
||||
|
||||
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
|
||||
@@ -82,14 +88,11 @@ test('正本指名由 issue-create 寫入,並提醒先以 --dry-run 檢查', (
|
||||
|
||||
test('正本逐一交代九個段落,且順序與模板一致', () => {
|
||||
// 只看「組出議題內容」那份編號清單,不看散落在行文裡的提及
|
||||
const listed = [...prompt.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)].map((m) => m[1].trim());
|
||||
assert.deepEqual(listed, SECTIONS);
|
||||
assertPromptListsSections(prompt, SECTIONS);
|
||||
});
|
||||
|
||||
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
|
||||
assert.equal(template.includes('```mermaid'), false);
|
||||
const section = template.slice(template.indexOf('## 流程圖'), template.indexOf('## 驗收標準'));
|
||||
assert.match(section.trim(), /^## 流程圖\s+\{\{流程圖\}\}$/);
|
||||
assertDiagramPlaceholderOnly(template, '流程圖', '驗收標準');
|
||||
});
|
||||
|
||||
test('正本交代了畫與不畫兩種情況各該填什麼', () => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 工作包議題的模板,以及正本裡「產生工作包」那一段的規則。
|
||||
*
|
||||
* 模板的段落順序決定 #9 的 wp-extract 解析得到什麼;待辦的巢狀寫法決定實作階段
|
||||
* 勾得到哪一行。這兩件事寫死在測試裡,改動時才會被逼著一起改。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assertDiagramPlaceholderOnly,
|
||||
assertPromptListsSections,
|
||||
assertTemplateSections,
|
||||
readPrompt,
|
||||
readTemplate,
|
||||
} from './helpers/prompt-doc.js';
|
||||
|
||||
const template = readTemplate('work-package-issue');
|
||||
const prompt = readPrompt('sdlc-analyze');
|
||||
/** 第二段的內容,避免把第一段的字樣誤認成這一段的規則 */
|
||||
const phase2 = prompt.slice(prompt.indexOf('## 第二段'));
|
||||
|
||||
/** 工作包議題的九個段落,順序即議題裡的順序 */
|
||||
const SECTIONS = [
|
||||
'這個工作包在做什麼',
|
||||
'描述',
|
||||
'架構圖',
|
||||
'範圍邊界',
|
||||
'介面契約',
|
||||
'待辦',
|
||||
'整體驗收',
|
||||
'repo 列表',
|
||||
'關聯',
|
||||
];
|
||||
|
||||
// ── 輸出模板 ───────────────────────────────────────────────────────
|
||||
|
||||
test('模板依序包含九個段落', () => {
|
||||
assertTemplateSections(template, SECTIONS);
|
||||
});
|
||||
|
||||
test('模板每個段落都有 {{變數}} 佔位', () => {
|
||||
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)];
|
||||
assert.equal(placeholders.length, SECTIONS.length);
|
||||
});
|
||||
|
||||
test('介面契約是四欄表格:介面/產出者/消費者/形狀', () => {
|
||||
const section = template.slice(template.indexOf('## 介面契約'), template.indexOf('## 待辦'));
|
||||
assert.match(section, /\|\s*介面\s*\|\s*產出者\s*\|\s*消費者\s*\|\s*形狀\s*\|/);
|
||||
assert.match(section, /\|\s*---\s*\|/, '要有分隔列,wp-extract 以它為界找資料列');
|
||||
});
|
||||
|
||||
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
|
||||
assertDiagramPlaceholderOnly(template, '架構圖', '範圍邊界');
|
||||
});
|
||||
|
||||
// ── 產生工作包那一段 ───────────────────────────────────────────────
|
||||
|
||||
test('第二段要等使用者對共識摘要點頭才開始', () => {
|
||||
assert.match(phase2, /點頭之後才開始/);
|
||||
assert.match(phase2, /沒有經過確認就不要往下走/);
|
||||
});
|
||||
|
||||
test('正本逐一交代九個段落,且順序與模板一致', () => {
|
||||
assertPromptListsSections(phase2, SECTIONS);
|
||||
});
|
||||
|
||||
test('標題規則為動詞加名詞,且明令禁止流水編號', () => {
|
||||
assert.match(phase2, /\{動詞\}\{名詞\}/);
|
||||
assert.match(phase2, /禁止流水編號/);
|
||||
assert.match(phase2, /WP-01/, '要舉出被禁止的寫法,不要只說「不要用編號」');
|
||||
});
|
||||
|
||||
test('待辦的巢狀寫法有具體範例,且說明上層與縮排各代表什麼', () => {
|
||||
const example = phase2.match(/```[^\n]*\n([\s\S]*?)```/)?.[1] ?? '';
|
||||
const indents = example
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('- ['))
|
||||
.map((line) => line.match(/^\s*/)[0].length);
|
||||
|
||||
assert.ok(indents.length >= 3, '範例要有數行待辦才看得出結構');
|
||||
assert.ok(
|
||||
Math.max(...indents) > Math.min(...indents),
|
||||
'範例要真的有縮排出來的巢狀層,不能只用文字描述',
|
||||
);
|
||||
assert.match(phase2, /上層是待辦、縮排一層是該項的驗收/);
|
||||
assert.match(phase2, /不要再往下巢狀/);
|
||||
});
|
||||
|
||||
test('範圍邊界要求明列不做什麼', () => {
|
||||
assert.match(phase2, /明列\*\*不做什麼\*\*/);
|
||||
assert.match(phase2, /抵抗範圍蔓延/);
|
||||
});
|
||||
|
||||
test('介面契約段落交代了沒有對外介面時怎麼填', () => {
|
||||
assert.match(phase2, /不要留空表/);
|
||||
});
|
||||
|
||||
test('關聯段落必須指回來源需求議題', () => {
|
||||
assert.match(phase2, /需求議題:#/);
|
||||
});
|
||||
|
||||
test('架構圖依性質三選一,並釘住節點與字數上限', () => {
|
||||
for (const kind of ['sequenceDiagram', 'flowchart', 'stateDiagram-v2']) {
|
||||
assert.match(prompt, new RegExp(kind));
|
||||
}
|
||||
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
|
||||
assert.match(limits, /12/);
|
||||
assert.match(limits, /8\s*字/);
|
||||
assert.match(limits, /拆成多張圖|不畫/);
|
||||
});
|
||||
|
||||
test('寫入前先試跑,且試跑的價值有被說明', () => {
|
||||
assert.match(phase2, /--dry-run/);
|
||||
assert.match(phase2, /no-op/, '要說明試跑會顯示「實跑是 no-op」,否則使用者不知道該看什麼');
|
||||
});
|
||||
|
||||
test('標籤只能從既有標籤挑,且指名用 labels-list 取得', () => {
|
||||
assert.match(phase2, /labels-list/);
|
||||
assert.match(phase2, /不得自行建立新標籤/);
|
||||
});
|
||||
|
||||
test('冪等查重有被交代:重跑不會產生第二顆', () => {
|
||||
assert.match(phase2, /重跑不會產生重複工作包/);
|
||||
assert.match(phase2, /created.*false|`created` 設為 `false`/);
|
||||
});
|
||||
|
||||
test('第二段明列它「不做」的事,避免搶走後續流程的工作', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不建相依/);
|
||||
assert.match(boundary, /不掛 Milestone/);
|
||||
assert.match(boundary, /不加看板/);
|
||||
assert.match(boundary, /不寫人天估算/);
|
||||
});
|
||||
|
||||
test('「不畫圖」是有條件的退路,不是免死金牌', () => {
|
||||
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
|
||||
assert.match(limits, /只在超過上限拆不開、或畫了不會比文字更清楚時/);
|
||||
});
|
||||
Reference in New Issue
Block a user