feat/sdlc-plan-requirement-issue/main #21

Merged
admin merged 6 commits from feat/sdlc-plan-requirement-issue/main into master 2026-09-17 04:51:03 +00:00
11 changed files with 749 additions and 20 deletions
+97
View File
@@ -0,0 +1,97 @@
name: sdlc-plan
description: 僅由 /sdlc-plan 指令叫用。把一段口語需求轉成結構化的需求議題,寫入 Gitea。
# sdlc-plan
把使用者給的一段需求,變成一顆結構完整、下游指令讀得動的需求議題。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
使用者給的東西可能是下列任一種,也可能三種混用:
- **自由文字** — 一段口語描述。
- **規格檔** — 一個檔案路徑,內容是既有的規格或筆記。
- **議題編號** — 既有議題的編號,用來補充脈絡或作為延伸的起點。
先把三種來源讀齊,再開始問問題。規格檔用檔案讀取工具讀;議題編號用
`scripts/issue-extract.js` 取(若該腳本尚未可用,改用 `scripts/issue-create.js` 以外的
既有讀取途徑,並在摘要中註明資料來源)。
## 步驟
### 1. 讀齊輸入,列出還缺什麼
把九個段落逐一對照使用者給的材料,列出哪些段落已經有依據、哪些沒有。
### 2. 逐項詢問
**一次問一題**,等使用者回答完再問下一題,讓他能看著前一題的答案回答下一題。
每一題都附上你的建議與理由,讓使用者多數時候只要點頭;同時保留讓他自己寫答案的餘地。
**未獲得答覆的欄位不得自行編造。** 使用者沒說過的目標、沒提過的驗收標準,一個字都不能自己
填。問不到就放進「未決事項」,那一段本來就是給未決的東西用的。
### 3. 組出議題內容
套用 `templates/requirement-issue.md`,依序填滿九個段落:
1. **總覽** — 一句話講完這件事在做什麼,讓非技術的利害關係人不必讀完技術細節。圖解版總覽的
連結此時先留空,由後續流程回填。
2. **背景** — 不超過三行。為什麼現在要做這件事。
3. **目標** — 可量測。寫得出「怎樣算達成」才算數。
4. **非目標** — 明列這次不做什麼,用來抵抗範圍蔓延。
5. **領域名詞表** — 這份需求裡會反覆出現的詞,各給一行定義,讓團隊對同一個詞的理解一致。
6. **流程圖** — 見下方「流程圖的限制」。
7. **驗收標準** — 逐條列出,每一條都要能被驗證。
8. **影響範圍** — 會動到哪些 repo、哪些既有功能。
9. **未決事項** — 問不到答案、或需要他人拍板的事。
### 4. 挑標籤
先用 `scripts/labels-list.js --repo <owner/name>` 取得該 repo 的既有標籤,**只能從這份清單裡
挑**。找不到合適的就不貼。**不得自行建立新標籤** —— 標籤體系由專案維護者決定,不該在多個
repo 之間長出雜草。
### 5. 先試跑,再寫入
把組好的內容寫到一個暫存檔,然後:
```
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> \
--labels "<標籤1,標籤2>" --dry-run
```
`--dry-run` 會印出將要送出的請求而不真的寫入。確認無誤後拿掉該旗標再跑一次。
同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆
並把 `created` 設為 `false`。
### 6. 回報
把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。
## 流程圖的限制
用 Mermaid 的 `flowchart`。節點數上限 **12**,每個節點的文字上限 **8 字**。
超過就拆成多張圖,或者乾脆不畫 —— 一張塞了二十個節點的圖,比沒有圖更難懂。
節點文字寫該步驟在做什麼,不要寫成編號或代號。
模板的 `{{流程圖}}` 要填入**完整的內容**,兩種形式擇一:
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
- 不畫:一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。
圍欄寫在填入的內容裡而不是模板裡,否則不畫圖時會留下一個空的 mermaid 區塊,
在議題頁上是一塊渲染失敗的紅字。
## 邊界
- 不修改使用者的專案檔案。這個流程只讀輸入、寫 Gitea 議題。
- 不建立標籤、不建立 Milestone、不建立專案看板。
- 不關閉或刪除任何既有議題。
- 規劃階段本身已含問題釐清,因此寫入 Gitea 前不再設額外的確認點;`--dry-run` 就是那道關卡。
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* 建立議題。
*
* 兩個刻意的限制:
* - 標籤只能指定 repo 上已經存在的,指到不存在的就中止。本工具不建立標籤。
* - 以標題查重,同名議題已存在就回傳既有那一顆,讓中斷後重跑不產生重複議題。
*
* `--dry-run` 不寫入,但會讀:要讓預覽忠實反映將送出的請求,就得先把標籤名稱換成 id、
* 也得先查過重——否則預覽看起來會建一顆議題,實跑卻是 no-op,或反過來實跑才爆標籤錯字。
*
* 用法:
* node scripts/issue-create.js --repo owner/name --title <標題> --body-file <路徑>
* [--labels a,b] [--host <網址>] [--dry-run]
*/
import { existsSync, readFileSync } from 'node:fs';
import {
ScriptError,
expectOk,
findIssueByTitle,
giteaRequest,
listLabels,
main,
parseFlags,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'title', 'body-file'],
optional: ['labels', 'host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const title = flags.title.trim();
const body = readBody(flags['body-file']);
const labelNames = splitLabels(flags.labels);
const path = `/repos/${repo}/issues`;
const dryRun = flags['dry-run'] === true;
const login = resolveLogin({ host: flags.host });
// 試跑不做前置檢查:那一層擋的是寫入能力與時間追蹤,而試跑本來就不寫。
if (!dryRun) await preflight(login, repo);
// 先驗標籤再查重:參數打錯要立刻講,不要等到重跑時才發現
const labelIds = labelNames.length > 0 ? await resolveLabelIds(login, repo, labelNames) : null;
const existing = await findIssueByTitle(login, repo, title);
const payload = { title, body };
if (labelIds) payload.labels = labelIds;
if (dryRun) {
return {
dryRun: true,
repo,
labels: labelNames,
existing: existing ? { number: existing.number, url: existing.html_url } : null,
// 同名議題已存在時實跑是 no-op,預覽就不該顯示將建立議題
requests: existing ? [] : [{ method: 'POST', path, body: payload }],
};
}
if (existing) {
return {
repo,
number: existing.number,
url: existing.html_url,
title: existing.title,
created: false,
};
}
const issue = expectOk(await giteaRequest(login, 'POST', path, { body: payload }), `POST ${path}`);
return {
repo,
number: issue.number,
url: issue.html_url,
title: issue.title,
labels: (issue.labels ?? []).map((label) => label.name),
created: true,
};
});
function readBody(bodyFile) {
if (!existsSync(bodyFile)) {
throw new ScriptError('BODY_FILE_MISSING', `找不到 --body-file 指定的檔案 ${bodyFile}`);
}
return readFileSync(bodyFile, 'utf8');
}
function splitLabels(value) {
if (value === undefined) return [];
return value
.split(',')
.map((name) => name.trim())
.filter((name) => name !== '');
}
/**
* 把標籤名稱換成 repo 上既有標籤的 id。指到不存在的標籤一律中止 ——
* 本工具不建立標籤,錯字應該當場講清楚,而不是悄悄少貼一個。
*/
async function resolveLabelIds(login, repo, names) {
const labels = await listLabels(login, repo);
const byName = new Map(labels.map((label) => [label.name, label.id]));
const missing = names.filter((name) => !byName.has(name));
if (missing.length > 0) {
throw new ScriptError(
'UNKNOWN_LABEL',
`${repo} 沒有這些標籤:${missing.join('、')}。` +
`本工具不建立標籤,請改挑既有的:${[...byName.keys()].join('、') || '(這個 repo 目前沒有任何標籤)'}`,
);
}
return names.map((name) => byName.get(name));
}
+2 -3
View File
@@ -8,8 +8,7 @@
* 用法:node scripts/labels-list.js --repo owner/name [--host <網址>] [--dry-run] * 用法:node scripts/labels-list.js --repo owner/name [--host <網址>] [--dry-run]
*/ */
import { import {
expectOk, listLabels,
giteaRequest,
main, main,
parseFlags, parseFlags,
parseRepo, parseRepo,
@@ -40,7 +39,7 @@ main(async () => {
const login = resolveLogin({ host: flags.host }); const login = resolveLogin({ host: flags.host });
await preflight(login, repo); await preflight(login, repo);
const labels = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; const labels = await listLabels(login, repo);
return { return {
repo, repo,
+15
View File
@@ -418,6 +418,21 @@ function checkTimeTracker(info) {
} }
} }
// ── 標籤 ───────────────────────────────────────────────────────────
/**
* 取得 repo 上的既有標籤。
* 本專案不建立標籤,所以這是取得標籤的唯一途徑:要貼標籤的腳本先從這裡拿清單,
* 挑不到合適的就不貼。
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @returns {Promise<object[]>}
*/
export async function listLabels(login, repo) {
const path = `/repos/${repo}/labels`;
return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
}
// ── 冪等查重 ─────────────────────────────────────────────────────── // ── 冪等查重 ───────────────────────────────────────────────────────
/** /**
+39
View File
@@ -0,0 +1,39 @@
## 總覽
{{總覽}}
圖解版總覽:{{總覽網頁}}
## 背景
{{背景}}
## 目標
{{目標}}
## 非目標
{{非目標}}
## 領域名詞表
| 名詞 | 定義 |
| --- | --- |
{{名詞表}}
## 流程圖
{{流程圖}}
## 驗收標準
{{驗收標準}}
## 影響範圍
{{影響範圍}}
## 未決事項
{{未決事項}}
+14
View File
@@ -89,3 +89,17 @@ function safeParse(raw) {
return raw; return raw;
} }
} }
/**
* 啟動假 Gitea 並登記在測試結束時關掉,省去每支測試各寫一次 close。
* @param {object} t node:test 的 TestContext
* @param {Record<string, object|Function>} routes
*/
export async function withStubGitea(t, routes) {
const stub = await startStubGitea(routes);
t.after(() => stub.close());
return stub;
}
/** 把腳本指向這台假 Gitea 的環境變數 */
export const stubEnv = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
+329
View File
@@ -0,0 +1,329 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { runScript, tmpRoot } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const TITLE = '把口語需求轉成結構化需求議題';
/** 建議題要帶 body,寫成檔案傳進去,避免長 markdown 擠在命令列上 */
function writeBody(text = '## 總覽\n\n一句話說明這件事在做什麼。\n') {
mkdirSync(tmpRoot, { recursive: true });
const dir = mkdtempSync(join(tmpRoot, 'body-'));
const path = join(dir, 'requirement.md');
writeFileSync(path, text);
return path;
}
/** 預設情境:repo 裡已經有兩個標籤,且沒有任何同名議題 */
function defaultRoutes(overrides = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [] },
[`POST /api/v1/repos/${REPO}/issues`]: (req) => ({
status: 201,
body: {
number: 42,
title: req.body.title,
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/42`,
labels: (req.body.labels ?? []).map((id) => ({ id, name: `label-${id}` })),
},
}),
...overrides,
});
}
async function withStub(t, overrides = {}) {
return withStubGitea(t, defaultRoutes(overrides));
}
// ── 建立議題 ───────────────────────────────────────────────────────
test('建立議題並回傳編號與網址', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.number, 42);
assert.equal(json.data.title, TITLE);
assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/issues/42`);
assert.equal(json.data.created, true);
});
test('body 由檔案讀入,原樣送出不做加工', async (t) => {
const stub = await withStub(t);
const body = '## 總覽\n\n多行的\n\n內容。\n';
await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(body)],
{ env: envFor(stub) },
);
const post = stub.requests.find((r) => r.method === 'POST');
assert.equal(post.body.body, body);
assert.equal(post.body.title, TITLE);
});
test('body 檔案不存在時,帶著路徑失敗且不碰 Gitea', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', '/nonexistent/body.md'],
{ env: envFor(stub) },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'BODY_FILE_MISSING');
assert.match(json.error.message, /\/nonexistent\/body\.md/);
assert.equal(stub.requests.length, 0);
});
test('缺少必填 flag 時失敗', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('issue-create.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(json.error.code, 'MISSING_FLAG');
});
// ── 標籤:只能挑既有的 ─────────────────────────────────────────────
test('以名稱指定標籤,送出時換成既有標籤的 id', async (t) => {
const stub = await withStub(t);
const { json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'],
{ env: envFor(stub) },
);
assert.equal(json.ok, true, JSON.stringify(json));
const post = stub.requests.find((r) => r.method === 'POST');
assert.deepEqual(post.body.labels, [55]);
});
test('多個標籤以逗號分隔', async (t) => {
const stub = await withStub(t);
await runScript(
'issue-create.js',
[
'--repo', REPO, '--title', TITLE, '--body-file', writeBody(),
'--labels', 'ready-for-agent,進行中',
],
{ env: envFor(stub) },
);
const post = stub.requests.find((r) => r.method === 'POST');
assert.deepEqual(post.body.labels, [55, 56]);
});
test('指定不存在的標籤時中止,並列出可選的標籤', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'needs-triage'],
{ env: envFor(stub) },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'UNKNOWN_LABEL');
assert.match(json.error.message, /needs-triage/);
assert.match(json.error.message, /ready-for-agent/, '訊息要列出可選的標籤');
});
test('不建立標籤:沒有任何請求寫到 labels 端點', async (t) => {
const stub = await withStub(t);
await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--labels', 'ready-for-agent'],
{ env: envFor(stub) },
);
const labelWrites = stub.requests.filter(
(r) => r.path.endsWith('/labels') && r.method !== 'GET',
);
assert.deepEqual(labelWrites, []);
});
test('未指定標籤時不查標籤,也不在請求裡帶 labels', async (t) => {
const stub = await withStub(t);
await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
assert.equal(stub.requests.some((r) => r.path.endsWith('/labels')), false);
const post = stub.requests.find((r) => r.method === 'POST');
assert.equal('labels' in post.body, false);
});
// ── 冪等查重 ───────────────────────────────────────────────────────
test('同標題議題已存在時不重建,回傳既有那一顆', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues`]: {
status: 200,
body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }],
},
});
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
assert.equal(code, 0);
assert.equal(json.data.created, false);
assert.equal(json.data.number, 9);
assert.equal(stub.requests.some((r) => r.method === 'POST'), false, '不得重建議題');
});
test('標題只差前後空白仍視為同一顆,不重建', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues`]: {
status: 200,
body: [{ number: 9, title: ` ${TITLE} `, html_url: 'https://example.com/9' }],
},
});
const { json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
assert.equal(json.data.created, false);
assert.equal(json.data.number, 9);
});
test('查重看的是 open 與 closed 兩種狀態', async (t) => {
const stub = await withStub(t);
await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
const lookup = stub.requests.find((r) => r.method === 'GET' && r.path.endsWith('/issues'));
assert.equal(lookup.query.state, 'all');
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的請求,但一個字都不寫進 Gitea', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'],
{ env: envFor(stub) },
);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[`POST /repos/${REPO}/issues`],
);
assert.equal(
stub.requests.some((r) => r.method !== 'GET'),
false,
'試跑可以讀,但不得發出任何寫入請求',
);
});
test('--dry-run 的預覽忠實反映將送出的 body,標籤已換成 id', async (t) => {
const stub = await withStub(t);
const body = '## 總覽\n\n要送出去的內容。\n';
const { json } = await runScript(
'issue-create.js',
[
'--repo', REPO, '--title', TITLE, '--body-file', writeBody(body),
'--labels', 'ready-for-agent', '--dry-run',
],
{ env: envFor(stub) },
);
const planned = json.data.requests[0].body;
assert.equal(planned.title, TITLE);
assert.equal(planned.body, body);
assert.deepEqual(planned.labels, [55], '預覽要看得出標籤會被貼上,而不是整個消失');
assert.deepEqual(json.data.labels, ['ready-for-agent']);
});
test('--dry-run 就抓得到標籤錯字,不必等到實跑', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript(
'issue-create.js',
[
'--repo', REPO, '--title', TITLE, '--body-file', writeBody(),
'--labels', 'needs-triage', '--dry-run',
],
{ env: envFor(stub) },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'UNKNOWN_LABEL');
});
test('--dry-run 遇到同名議題時,如實顯示實跑會是 no-op', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues`]: {
status: 200,
body: [{ number: 9, title: TITLE, html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/9` }],
},
});
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody(), '--dry-run'],
{ env: envFor(stub) },
);
assert.equal(code, 0);
assert.deepEqual(json.data.requests, [], '已存在就不該預告要建立議題');
assert.equal(json.data.existing.number, 9);
});
// ── 前置檢查仍然生效 ───────────────────────────────────────────────
test('寫入型腳本一樣跑前置檢查:時間追蹤沒開就中止', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
permissions: { admin: true, push: true, pull: true },
internal_tracker: { enable_time_tracker: false },
},
},
});
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
assert.equal(stub.requests.some((r) => r.method === 'POST'), false);
});
+2 -7
View File
@@ -1,20 +1,15 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js'; import { runScript } from './helpers/run-script.js';
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc'; const REPO = 'plugins/tea-sdlc';
/** 讓每個測試都拿到一台乾淨的假 Gitea,並在結束後關掉 */ /** 讓每個測試都拿到一台乾淨的假 Gitea,並在結束後關掉 */
async function withStub(t, overrides = {}) { async function withStub(t, overrides = {}) {
const stub = await startStubGitea(healthyRoutes(REPO, overrides)); return withStubGitea(t, healthyRoutes(REPO, overrides));
t.after(() => stub.close());
return stub;
} }
function envFor(stub) {
return { TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' };
}
test('列出目標 repo 的既有標籤,輸出單行 JSON 且 exit 0', async (t) => { test('列出目標 repo 的既有標籤,輸出單行 JSON 且 exit 0', async (t) => {
const stub = await withStub(t); const stub = await withStub(t);
+2 -5
View File
@@ -1,17 +1,14 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { runScript, pathWithOnly } from './helpers/run-script.js'; import { runScript, pathWithOnly } from './helpers/run-script.js';
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc'; const REPO = 'plugins/tea-sdlc';
async function withStub(t, overrides = {}) { async function withStub(t, overrides = {}) {
const stub = await startStubGitea(healthyRoutes(REPO, overrides)); return withStubGitea(t, healthyRoutes(REPO, overrides));
t.after(() => stub.close());
return stub;
} }
const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
// ── 第一層:執行環境 ──────────────────────────────────────────────── // ── 第一層:執行環境 ────────────────────────────────────────────────
+2 -5
View File
@@ -6,17 +6,14 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js'; import { runScript } from './helpers/run-script.js';
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc'; const REPO = 'plugins/tea-sdlc';
async function withStub(t, overrides = {}) { async function withStub(t, overrides = {}) {
const stub = await startStubGitea(healthyRoutes(REPO, overrides)); return withStubGitea(t, healthyRoutes(REPO, overrides));
t.after(() => stub.close());
return stub;
} }
const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
// ── 輸入:具名 flag ──────────────────────────────────────────────── // ── 輸入:具名 flag ────────────────────────────────────────────────
+127
View File
@@ -0,0 +1,127 @@
/**
* 流程正本與輸出模板的結構驗證。
*
* 這兩份是檔案而非程式,但它們是 #4 實際交付的東西:模板段落順序決定了下游
* issue-extract 解析得到什麼,正本的平台中立性決定了轉接檔能不能一份寫到底。
* 用測試釘住,比靠人記得住可靠。
*/
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 template = readFileSync(join(repoRoot, 'templates', 'requirement-issue.md'), 'utf8');
const prompt = readFileSync(join(repoRoot, 'prompts', 'sdlc-plan.md'), 'utf8');
/** 需求議題的九個段落,順序即議題裡的順序 */
const SECTIONS = [
'總覽',
'背景',
'目標',
'非目標',
'領域名詞表',
'流程圖',
'驗收標準',
'影響範圍',
'未決事項',
];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含九個段落', () => {
const headings = [...template.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
assert.deepEqual(headings, SECTIONS);
});
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]);
assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位');
for (const name of placeholders) {
assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`);
}
});
test('總覽段落預留了總覽網頁的連結佔位', () => {
const overview = template.slice(template.indexOf('## 總覽'), template.indexOf('## 背景'));
assert.match(overview, /\{\{總覽\}\}/);
assert.match(overview, /\{\{總覽網頁\}\}/);
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本的 description 以「僅由 /sdlc-plan 指令叫用。」起頭', () => {
const description = prompt.match(/^description:\s*(.+)$/m)?.[1];
assert.ok(description, '正本需要一行 description 供轉接檔取用');
assert.ok(
description.startsWith('僅由 /sdlc-plan 指令叫用。'),
`description 前綴不符:${description}`,
);
});
test('正本是平台中立的:不出現任何特定助理的語法或名稱', () => {
const platformSpecific = [
'AskUserQuestion',
'Claude',
'Codex',
'Antigravity',
'Copilot',
'Kiro',
'OpenCode',
'oh-my-pi',
'.claude',
'$sdlc-plan',
];
for (const token of platformSpecific) {
assert.equal(prompt.includes(token), false, `正本不該出現平台專屬字樣:${token}`);
}
});
test('正本沒有 YAML frontmatter:那是各平台轉接檔的事', () => {
assert.equal(prompt.startsWith('---'), false);
});
test('正本交代了三種輸入都要能吃', () => {
for (const kind of ['自由文字', '規格檔', '議題編號']) {
assert.match(prompt, new RegExp(kind), `正本要說明輸入可為${kind}`);
}
});
test('正本明令缺漏資訊要逐項問,不得自行編造', () => {
assert.match(prompt, /一次問一題|逐項詢問/);
assert.match(prompt, /不得(自行|替使用者)?(編造|填入)/);
});
test('正本釘住 Mermaid flowchart 的節點上限與字數上限', () => {
assert.match(prompt, /flowchart/);
assert.match(prompt, /12/);
assert.match(prompt, /8\s*字/);
assert.match(prompt, /拆(成多)?圖|不畫/);
});
test('正本要求標籤只能從既有標籤挑,並指名用 labels-list 取得', () => {
assert.match(prompt, /labels-list/);
assert.match(prompt, /不(得|能)(自行)?建立(新)?標籤/);
});
test('正本指名由 issue-create 寫入,並提醒先以 --dry-run 檢查', () => {
assert.match(prompt, /issue-create/);
assert.match(prompt, /--dry-run/);
});
test('正本逐一交代九個段落,且順序與模板一致', () => {
// 只看「組出議題內容」那份編號清單,不看散落在行文裡的提及
const listed = [...prompt.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)].map((m) => m[1].trim());
assert.deepEqual(listed, SECTIONS);
});
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
assert.equal(template.includes('```mermaid'), false);
const section = template.slice(template.indexOf('## 流程圖'), template.indexOf('## 驗收標準'));
assert.match(section.trim(), /^## 流程圖\s+\{\{流程圖\}\}$/);
});
test('正本交代了畫與不畫兩種情況各該填什麼', () => {
assert.match(prompt, /```mermaid/);
assert.match(prompt, /不要加圍欄|不加圍欄/);
});