/** * README 的安裝段。 * * 這一段是使用者照著打的東西,打錯一個字就裝不起來,所以指令要能逐字複製執行, * 而且不能留下已經不成立的說法——並列一條「照著做不會出現任何指令」的流程等於在說謊。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { repoRoot, runBin, tmpRoot } from './helpers/run-script.js'; const readme = () => readFileSync(join(repoRoot, 'README.md'), 'utf8'); const agents = () => readFileSync(join(repoRoot, 'AGENTS.md'), 'utf8'); /** 取出所有 bash 圍欄裡的指令行 */ function commands(text) { return [...text.matchAll(/```bash\n([\s\S]*?)```/g)] .flatMap((block) => block[1].split('\n')) .map((line) => line.trim()) .filter((line) => line !== '' && !line.startsWith('#')); } test('安裝以 npm 為唯一建議路徑,指令含完整可複製的 git URL', () => { const install = commands(readme()).filter((line) => line.startsWith('npm i -g')); assert.equal(install.length > 0, true, 'README 沒有 npm 安裝指令'); assert.ok( install.some((line) => line.includes('https://gitea.jsc.idv.tw/plugins/tea-sdlc.git')), `安裝指令少了完整 git URL:${install.join(' / ')}`, ); }); test('更新與移除各有完整指令表,順序講清楚先 uninstall 再 npm rm', () => { const text = readme(); assert.match(text, /npm i -g https:\/\/gitea\.jsc\.idv\.tw\/plugins\/tea-sdlc\.git/); assert.match(text, /tea-sdlc uninstall/); assert.match(text, /npm rm -g tea-sdlc/); // 先砍套件就再也刪不掉那些孤兒轉接檔,順序本身就是內容 assert.ok( text.indexOf('tea-sdlc uninstall') < text.indexOf('npm rm -g tea-sdlc'), 'uninstall 必須寫在 npm rm -g 之前', ); }); test('明寫不要用 npm update -g,並說出為什麼', () => { const text = readme(); assert.match(text, /npm update -g/); const at = text.indexOf('npm update -g'); assert.match(text.slice(at - 200, at + 200), /不要|別/); }); test('四個子指令在 README 上都有用途說明', () => { const text = readme(); for (const name of ['install', 'uninstall', 'prompt', 'status']) { assert.match(text, new RegExp(`tea-sdlc ${name}`), `README 沒有交代 ${name}`); } }); test('七套 marketplace 流程移進附錄保留,內容沒有被刪掉', () => { const text = readme(); const appendix = text.slice(text.indexOf('## 附錄')); assert.ok(text.includes('## 附錄'), 'README 沒有附錄'); for (const line of ['claude plugin marketplace add', 'codex plugin marketplace add', 'agy plugin install']) { assert.ok(appendix.includes(line), `附錄少了 ${line}`); } }); test('prompt 與 status 各自交代了「誰在什麼時候用它」,不是只列出指令', () => { const text = readme(); const section = text.slice(text.indexOf('## 給 agent 的入口'), text.indexOf('## 附錄')); // prompt 是 agent 取得流程正本的管道 assert.match(section, /tea-sdlc prompt --name/); assert.match(section, /agent|模型/); // status 是使用者查安裝與環境現況的入口 assert.match(section, /tea-sdlc status/); assert.match(section, /環境/); }); test('前置需求表分得開「安裝」與「跑流程指令」各自缺什麼會怎樣', () => { const text = readme(); const section = text.slice(text.indexOf('## 前置需求'), text.indexOf('## 安裝')); // install 只警告不中止,表上不能再寫成一律中止——那是文件在描述一個不存在的行為 assert.equal( /缺少時腳本印出指引並中止/.test(section), false, '前置需求表還寫著一律中止,但 tea-sdlc install 只警告', ); assert.match(section, /install/); }); test('README 裡的 tea-sdlc 指令逐字拿去跑都認得,不會是寫給人看的假指令', async () => { const invocations = [...readme().matchAll(/```bash\n([\s\S]*?)```/g)] .flatMap((block) => block[1].split('\n')) .map((line) => line.trim()) .filter((line) => line.startsWith('tea-sdlc ')) // shell 會把空白後的 # 之後當註解,貼進終端機仍然跑得動,這裡照做 .map((line) => line.replace(/\s+#.*$/, '').trim()); assert.ok(invocations.length >= 4, `README 只找到 ${invocations.length} 個 tea-sdlc 指令`); for (const line of invocations) { // 家目錄指到空的暫存:install/uninstall 因此偵測不到任何平台,一個檔都不會寫 const home = mkdtempSync(join(tmpRoot, 'readme-home-')); const { code, raw } = await runBin(line.split(/\s+/).slice(1), { env: { HOME: home } }); rmSync(home, { recursive: true, force: true }); // 跑成功就沒話說(prompt 印的是原樣 markdown,本來就不是 JSON); // 失敗的話,理由不可以是「這個指令/參數我不認得」——那代表 README 寫錯了 if (code === 0) continue; const { error } = JSON.parse(raw.trim().split('\n').at(-1)); for (const wrong of ['UNKNOWN_SUBCOMMAND', 'UNKNOWN_FLAG', 'MISSING_FLAG', 'BAD_PROMPT_NAME']) { assert.notEqual(error.code, wrong, `README 的「${line}」跑出 ${wrong}:${error.message}`); } } }); test('模組邊界表指得到實際存在的檔案', () => { const text = agents(); assert.match(text, /`bin\/tea-sdlc\.js`/); assert.match(text, /`scripts\/install\.js`/); assert.equal(/\| `install\.js` \|/.test(text), false, '表上還留著已經不存在的根目錄 install.js'); }); test('指令入口那一列把職責與邊界都寫出來了', () => { const row = agents().split('\n').find((line) => line.includes('`bin/tea-sdlc.js`')); assert.ok(row, 'AGENTS.md 模組邊界表沒有指令入口那一列'); const [, , duty, boundary] = row.split('|').map((cell) => cell.trim()); // 職責是子指令解析與 dispatch assert.match(duty, /子指令/); assert.match(duty, /dispatch|交出去/); // 邊界是不含任何平台目錄知識與動作實作 assert.match(boundary, /平台目錄/); assert.match(boundary, /實作|做事/); });