diff --git a/test/bin-entry.test.js b/test/bin-entry.test.js index 54fb8fd..7926bba 100644 --- a/test/bin-entry.test.js +++ b/test/bin-entry.test.js @@ -81,7 +81,8 @@ test('打包內容以白名單決定:四個正本目錄都在,測試與暫 const packed = JSON.parse( execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: repoRoot, encoding: 'utf8' }), ); - const files = packed[0].files.map((file) => file.path); + const manifest = Array.isArray(packed) ? packed[0] : packed[Object.keys(packed)[0]]; + const files = manifest.files.map((file) => file.path); for (const dir of ['prompts/', 'scripts/', 'templates/', 'references/', 'bin/']) { assert.ok( diff --git a/test/delegation-assets.test.js b/test/delegation-assets.test.js new file mode 100644 index 0000000..58cd24a --- /dev/null +++ b/test/delegation-assets.test.js @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readReference, readPrompt, assertNeutralPrompt } from './helpers/prompt-doc.js'; + +const RULES = readReference('delegation'); +const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report']; +const EXPECTED = [ + ['sdlc-plan', '列出九段落依據與缺漏'], + ['sdlc-analyze', '對四份清單列出疑點'], + ['sdlc-analyze', '算出截止日'], + ['sdlc-feat', '把議題標題翻成英文'], + ['sdlc-feat', '分批提交方案'], +]; + +function tableEntries() { + return [...RULES.matchAll(/^\| `([^`]+)` \| ([^|]+) \|/gm)] + .map((match) => [match[1], match[2].trim()]); +} + +function promptMarkers(name) { + const prompt = readPrompt(name); + return [...prompt.matchAll(/^### (?:\d+\.\s+)?(.+?)〔可委派〕\s*$/gm)] + .map((match) => [name, match[1].trim()]); +} + +// ── 四條判準與雙向集合 ──────────────────────────────────────────── + +test('委派規則保留四條判準與兩條硬排除', () => { + for (const term of ['可驗證的成品', '不會詢問使用者', '失敗能被呼叫端偵測', '不直接寫入 Gitea 或 git']) { + assert.match(RULES, new RegExp(term)); + } +}); + +test('委派表與六份正本的標記集合完全一致', () => { + const table = tableEntries(); + const markers = PROMPTS.flatMap(promptMarkers); + assert.deepEqual(table, EXPECTED); + assert.deepEqual(markers, EXPECTED); +}); + +test('每個標記步驟都說明能力降級與部分委派邊界', () => { + for (const [name, step] of EXPECTED) { + const prompt = readPrompt(name); + const marker = prompt.match(new RegExp(`^### (?:\\d+\\.\\s+)?${step}〔可委派〕\\s*$`, 'm')); + assert.ok(marker, `${name} 缺少標記:${step}`); + const start = marker.index; + const body = prompt.slice(start, prompt.indexOf('\n### ', start + 1) === -1 ? prompt.length : prompt.indexOf('\n### ', start + 1)); + assert.match(body, /你的環境若能把工作交給子代理|實際執行.*不委派/); + } +}); + +// ── 六份正本的平台與文字契約 ────────────────────────────────────── + +test('六份流程正本均為平台中立且使用正確 description 前綴', () => { + for (const name of PROMPTS) assertNeutralPrompt(readPrompt(name), name); +}); + +test('沒有委派標記的流程仍明確列入規則表的空集合', () => { + for (const name of ['sdlc-fix', 'sdlc-sync', 'sdlc-report']) { + assert.equal(promptMarkers(name).length, 0, `${name} 不應偷偷出現委派步驟`); + } + assert.match(RULES, /sdlc-sync.*sdlc-fix.*sdlc-report/); +}); diff --git a/test/encoding-assets.test.js b/test/encoding-assets.test.js new file mode 100644 index 0000000..ecc7299 --- /dev/null +++ b/test/encoding-assets.test.js @@ -0,0 +1,30 @@ +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 files = [ + ...['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report'] + .map((name) => join(repoRoot, 'prompts', `${name}.md`)), + join(repoRoot, 'references', 'delegation.md'), + join(repoRoot, 'AGENTS.md'), + join(repoRoot, 'README.md'), + join(repoRoot, 'docs', 'adr', '0001-以集中式雜湊路徑的-worktree-隔離平行工作包.md'), + join(repoRoot, 'docs', 'adr', '0002-以能力描述而非工具名表達委派.md'), +]; + +test('流程、規則與文件資產都是合法 UTF-8 且沒有替代字元', () => { + for (const path of files) { + const bytes = readFileSync(path); + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + assert.equal(text.includes('\uFFFD'), false, `${path} 含替代字元`); + } +}); + +test('六份流程正本都以繁體中文 description 開頭', () => { + for (const path of files.slice(0, 6)) { + const text = readFileSync(path, 'utf8'); + assert.match(text, /^description: 僅由 \/sdlc-[a-z-]+ 指令叫用。/m, path); + } +}); diff --git a/test/install-verify.test.js b/test/install-verify.test.js index d9dd70d..0e57110 100644 --- a/test/install-verify.test.js +++ b/test/install-verify.test.js @@ -9,7 +9,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { manifest, tmpRoot } from './helpers/run-script.js'; +import { manifest, pathWithOnly, tmpRoot } from './helpers/run-script.js'; import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js'; import { startStubGitea } from './helpers/stub-gitea.js'; @@ -114,7 +114,7 @@ test('PATH 上找不到 tea-sdlc 時整體 ok:false,並指出病灶在 PATH const plugin = makeFakePlugin(t, { prompts: PROMPTS }); const home = makeHome(t); - const { code, json } = await inHome(plugin, home)(['install'], { shim: false }); + const { code, json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) }); assert.equal(code, 1); assert.equal(json.ok, false); @@ -261,7 +261,7 @@ test('驗證失敗時已經寫好的轉接檔一份都不刪', async (t) => { const home = makeHome(t); // 病灶在 PATH,不在轉接檔:刪掉轉接檔只會讓使用者從「有點舊但能用」變成什麼都沒有 - const { json } = await inHome(plugin, home)(['install'], { shim: false }); + const { json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) }); assert.equal(json.ok, false); for (const path of adaptersOf(json)) { diff --git a/test/install.test.js b/test/install.test.js index 9d9079f..1eef008 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -176,6 +176,27 @@ test('支援 command 的四個平台產生 command 轉接檔,另外三個產 assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']); }); +test('七個平台的轉接檔都保留繁體中文且沒有 UTF-8 亂碼', async (t) => { + const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } }); + const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']); + + await inHome(plugin, home)(['install']); + const files = [ + join(home, '.claude', 'commands', 'sdlc-plan.md'), + join(home, '.codex', 'prompts', 'sdlc-plan.md'), + join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'), + join(home, '.omp', 'agent', 'commands', 'sdlc-plan.md'), + join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'), + join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'), + join(home, 'work', '.github', 'skills', 'sdlc-plan', 'SKILL.md'), + ]; + + for (const path of files) { + const text = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path)); + assert.match(text, /僅由 \/sdlc-plan 指令叫用。/); + } +}); + test('轉接檔內容是一句指向 tea-sdlc 的話,不含任何檔案路徑', async (t) => { const plugin = makeFakePlugin(t, { prompts: PROMPTS, version: '1.2.3' }); const home = makeHome(t, ['claude']);