install 寫完轉接檔後,把叫用鏈真的走一遍:轉接檔 → PATH 上的 tea-sdlc → 流程正本。
最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身
看起來完全正常——沒有這道驗證,使用者要到第一次打 /sdlc-plan 才發現,那時他已經離開
安裝的心智狀態很久了。所以不是查檔案在不在,而是真的到 PATH 上把 tea-sdlc 找出來執行
一次,再把取回的正本跟套件裡的那一份逐字比對:找不到、叫不動、或叫到的是另一份安裝,
三種都驗得出來。轉接檔則逐一回磁碟讀,比對存在且內容含正確的叫用行。
驗證不碰網路,也與 Gitea 登入、時間追蹤無關,所以無條件執行。
驗不過回 ok:false,但已經寫好的轉接檔一份都不刪。回滾在升級情境下是淨損失:原本有一組
能用的舊轉接檔,覆蓋後驗證失敗再刪掉,使用者就從「有點舊但能用」變成什麼都沒有;何況
最可能的病灶是「PATH 上找不到 tea-sdlc」,那不是轉接檔的問題。
為此 lib 多一個 Failure:有一種失敗是事情做完了、檔案也寫出去了,只是驗不過,那時最該
交出去的正是「已經寫了哪些、哪一段不通」。envelope 形狀不變,只是 {ok:false, error}
旁邊多一個 data,只讀 error.code 的呼叫端照常運作。
--dry-run 不寫入,也就沒有東西可驗,verify 標成 skipped。
Closes #59
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
7.9 KiB
JavaScript
181 lines
7.9 KiB
JavaScript
/**
|
||
* README 的安裝段。
|
||
*
|
||
* 這一段是使用者照著打的東西,打錯一個字就裝不起來,所以指令要能逐字複製執行,
|
||
* 而且不能留下已經不成立的說法——並列一條「照著做不會出現任何指令」的流程等於在說謊。
|
||
*/
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, readFileSync, readdirSync, 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('說明不得宣稱正本沒到齊——prompts/ 裡有幾份,說明就得跟著', () => {
|
||
// 「裝了也沒指令可用」這種過時的說法,會讓使用者以為工具還不能用而不去裝;
|
||
// 同一句話在 AGENTS.md 裡還會誤導下一個 agent。
|
||
const 正本數 = readdirSync(join(repoRoot, 'prompts')).filter((f) => f.endsWith('.md')).length;
|
||
|
||
for (const [名字, 內容] of [['README.md', readme()], ['AGENTS.md', agents()]]) {
|
||
const 說沒到齊 = /正本尚未到齊|仍在實作中/.test(內容);
|
||
assert.equal(
|
||
說沒到齊,
|
||
正本數 < 6,
|
||
`${名字} 對正本進度的說法與 prompts/ 的實際份數(${正本數})對不上`,
|
||
);
|
||
}
|
||
});
|
||
|
||
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('gitea.jsc.idv.tw/plugins/tea-sdlc.git')),
|
||
`安裝指令少了完整 git URL:${install.join(' / ')}`,
|
||
);
|
||
});
|
||
|
||
test('指向 .git 的安裝指令一律帶 git+ 前綴,否則 npm 會把它當壓縮檔去解', () => {
|
||
// npm 只有看到 git/git+ssh/git+http/git+https/git+file 才會當成 git repo;
|
||
// 純 https://….git 會被歸類成遠端 tarball,下載回來解壓失敗,錯誤訊息是
|
||
// TAR_BAD_ARCHIVE: Unrecognized archive format——看起來完全不像「網址寫法錯了」。
|
||
for (const line of commands(readme()).filter((l) => l.startsWith('npm i -g') && l.includes('.git'))) {
|
||
assert.match(line, /git\+https:\/\//, `這行 npm 跑不起來:${line}`);
|
||
}
|
||
});
|
||
|
||
test('更新與移除各有完整指令表,順序講清楚先 uninstall 再 npm rm', () => {
|
||
const text = readme();
|
||
|
||
assert.match(text, /npm i -g git\+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('README 交代了安裝會自動驗證,以及驗不過時轉接檔不會被回滾', () => {
|
||
// 「驗不過但檔案還在」如果沒寫出來,使用者看到 ok:false 的第一個念頭會是自己去清乾淨重裝,
|
||
// 那正好是這個設計要避免的事
|
||
const text = readme();
|
||
const section = text.slice(text.indexOf('### 安裝完成等於驗過能用'), text.indexOf('## 更新 / 移除'));
|
||
|
||
assert.ok(section.length > 0, 'README 沒有交代安裝後的驗證');
|
||
assert.match(section, /ok:false/);
|
||
assert.match(section, /不.{0,4}回滾|一份都不刪/);
|
||
assert.match(section, /dry-run/);
|
||
});
|
||
|
||
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, /實作|做事/);
|
||
});
|