@zhushanwen/pi-cw-tool 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -36
- package/package.json +5 -9
- package/skills/pi-cw/SKILL.md +33 -71
- package/src/__tests__/cw-tool.test.ts +240 -611
- package/src/cw-runner.ts +79 -292
- package/src/cw-spawn.ts +36 -5
- package/src/index.ts +56 -147
- package/agents/dev-agent.md +0 -92
- package/agents/merge-agent.md +0 -55
- package/agents/planning-agent.md +0 -133
- package/agents/review-agent.md +0 -95
- package/agents/wave-agent.md +0 -115
- package/skills/pi-cw/design-v4.md +0 -226
- package/src/__tests__/detect-repo-workspace.test.ts +0 -163
- package/src/__tests__/workspace-gate.test.ts +0 -261
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* workspace 门控测试:probeCwCliNormalization 纯函数 + executeCwAction 门控决策。
|
|
3
|
-
*
|
|
4
|
-
* 门控语义(cw-cli ADR-0014 store-workspace decoupling):cw-cli 支持 store 内部归一化
|
|
5
|
-
* (probe 版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION)→ write action 不传 --workspace(纯封装);
|
|
6
|
-
* 不支持 → 兜底 cw-cli ADR-0014 的 detectRepoWorkspace + --workspace。只读 action 始终不传(S-3)。
|
|
7
|
-
*
|
|
8
|
-
* detectRepoWorkspace 探测纯函数 + buildCwArgs 构造的测试在 detect-repo-workspace.test.ts,
|
|
9
|
-
* 本文件只测门控决策(probe 三态 × action 两类 × git 环境)。
|
|
10
|
-
*
|
|
11
|
-
* 测试框架:vitest(从 vitest 导入 describe/it/expect/vi)。
|
|
12
|
-
*/
|
|
13
|
-
import { execSync } from "node:child_process";
|
|
14
|
-
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
16
|
-
import * as path from "node:path";
|
|
17
|
-
|
|
18
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
-
|
|
20
|
-
import { _resetCapabilityCacheForTest, executeCwAction, probeCwCliNormalization } from "../cw-runner.ts";
|
|
21
|
-
import { type CwSpawner, type CwSpawnResult } from "../cw-spawn.ts";
|
|
22
|
-
import { DEV_ALLOWED } from "../index.ts";
|
|
23
|
-
|
|
24
|
-
// ── 临时目录管理(同 detect-repo-workspace.test.ts 风格)─────────
|
|
25
|
-
|
|
26
|
-
const tmpDirs: string[] = [];
|
|
27
|
-
|
|
28
|
-
function makeTempDir(prefix: string): string {
|
|
29
|
-
const dir = mkdtempSync(path.join(tmpdir(), prefix));
|
|
30
|
-
tmpDirs.push(dir);
|
|
31
|
-
return dir;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** 建含一次空 commit 的 git repo,返回 realpath 规范化的 repo 根(macOS /tmp→/private/tmp 对齐)。 */
|
|
35
|
-
function createGitRepo(parentDir: string, name: string): string {
|
|
36
|
-
const repo = path.join(parentDir, name);
|
|
37
|
-
mkdirSync(repo);
|
|
38
|
-
execSync("git init -q", { cwd: repo });
|
|
39
|
-
execSync("git -c user.name=test -c user.email=test@test.local commit -q --allow-empty -m init", {
|
|
40
|
-
cwd: repo,
|
|
41
|
-
});
|
|
42
|
-
return realpathSync(repo);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
afterEach(() => {
|
|
46
|
-
for (const dir of tmpDirs.splice(0)) {
|
|
47
|
-
rmSync(dir, { recursive: true, force: true });
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// capability 缓存是进程内全局单值(S-1),测试间必须重置避免串台。
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
_resetCapabilityCacheForTest();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// ── mock spawner:对 ['--version'] 可控,对 action 默认成功 ───────
|
|
57
|
-
|
|
58
|
-
interface GateSpawnerOpts {
|
|
59
|
-
/** cw --version 的 stdout(如 "cw 1.6.1")。 */
|
|
60
|
-
versionStdout?: string;
|
|
61
|
-
/** cw --version 的 exitCode(默认 0)。 */
|
|
62
|
-
versionExitCode?: number;
|
|
63
|
-
/** cw --version spawn 抛异常(模拟 cw 不在 PATH)。 */
|
|
64
|
-
versionThrow?: boolean;
|
|
65
|
-
/** action 调用的返回(默认成功 `{ stdout: "{}", exitCode: 0 }`,覆盖以测失败分支)。 */
|
|
66
|
-
actionResult?: CwSpawnResult;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* 记录所有调用的 fake spawner:对 `['--version']`(probe)返回可控结果,
|
|
71
|
-
* 对其他(action)返回默认成功 `{ stdout: "{}", exitCode: 0 }`。
|
|
72
|
-
*/
|
|
73
|
-
function gateSpawner(opts: GateSpawnerOpts = {}): {
|
|
74
|
-
spawner: CwSpawner;
|
|
75
|
-
calls: Array<{ args: string[] }>;
|
|
76
|
-
} {
|
|
77
|
-
const calls: Array<{ args: string[] }> = [];
|
|
78
|
-
// contextual typing:变量类型 CwSpawner 提供 4 参数完整签名,回调签名漂移会编译失败(消除假绿,S-5)。
|
|
79
|
-
const spawner: CwSpawner = vi.fn(async (args, _input, _cwd, _signal): Promise<CwSpawnResult> => {
|
|
80
|
-
calls.push({ args });
|
|
81
|
-
if (args[0] === "--version") {
|
|
82
|
-
if (opts.versionThrow) throw new Error("cw not in PATH (spawn error)");
|
|
83
|
-
return {
|
|
84
|
-
stdout: opts.versionStdout ?? "",
|
|
85
|
-
stderr: "",
|
|
86
|
-
exitCode: opts.versionExitCode ?? 0,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
return opts.actionResult ?? { stdout: "{}", stderr: "", exitCode: 0 };
|
|
90
|
-
});
|
|
91
|
-
return { spawner, calls };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** 从 calls 中取 action 调用(非 `['--version']` 的最后一个)。 */
|
|
95
|
-
function actionCall(calls: Array<{ args: string[] }>): { args: string[] } {
|
|
96
|
-
const action = calls.filter((c) => c.args[0] !== "--version").pop();
|
|
97
|
-
if (!action) throw new Error("无 action 调用(只有 probe 或未触发)");
|
|
98
|
-
return action;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ── probeCwCliNormalization 纯函数 ──────────────────────────────
|
|
102
|
-
|
|
103
|
-
describe("probeCwCliNormalization", () => {
|
|
104
|
-
it("版本 >= MIN_CW_CLI_VERSION_FOR_NORMALIZATION → supported:true", async () => {
|
|
105
|
-
const cwd = makeTempDir("probe-supported-");
|
|
106
|
-
const { spawner } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
107
|
-
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
108
|
-
expect(cap.supported).toBe(true);
|
|
109
|
-
expect(cap.version).toBe("99.9.9");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("版本 === MIN(等号边界)→ supported:true(门控用 >=,锁定等号语义,S-7)", async () => {
|
|
113
|
-
const cwd = makeTempDir("probe-eq-");
|
|
114
|
-
const { spawner } = gateSpawner({ versionStdout: "cw 1.6.2" });
|
|
115
|
-
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
116
|
-
expect(cap.supported).toBe(true);
|
|
117
|
-
expect(cap.version).toBe("1.6.2");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("版本 < MIN → supported:false(1.6.2 是归一化首版,1.6.1 不支持)", async () => {
|
|
121
|
-
const cwd = makeTempDir("probe-unsupported-");
|
|
122
|
-
const { spawner } = gateSpawner({ versionStdout: "cw 1.6.1" });
|
|
123
|
-
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
124
|
-
expect(cap.supported).toBe(false);
|
|
125
|
-
expect(cap.version).toBe("1.6.1");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("cw --version exitCode 非 0 → supported:false(fail-safe)", async () => {
|
|
129
|
-
const cwd = makeTempDir("probe-exit-");
|
|
130
|
-
const { spawner } = gateSpawner({ versionExitCode: 127 });
|
|
131
|
-
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
132
|
-
expect(cap.supported).toBe(false);
|
|
133
|
-
expect(cap.version).toBeUndefined();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("stdout 无版本号 → supported:false(parse 失败 fail-safe)", async () => {
|
|
137
|
-
const cwd = makeTempDir("probe-parse-");
|
|
138
|
-
const { spawner } = gateSpawner({ versionStdout: "garbage no version here" });
|
|
139
|
-
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
140
|
-
expect(cap.supported).toBe(false);
|
|
141
|
-
expect(cap.version).toBeUndefined();
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("spawn 抛异常(cw 不在 PATH)→ supported:false(fail-safe)", async () => {
|
|
145
|
-
const cwd = makeTempDir("probe-throw-");
|
|
146
|
-
const { spawner } = gateSpawner({ versionThrow: true });
|
|
147
|
-
const cap = await probeCwCliNormalization(spawner, cwd);
|
|
148
|
-
expect(cap.supported).toBe(false);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("probe 5s 超时 → supported:false(fail-safe,防御 cw --version 卡死,S-6)", async () => {
|
|
152
|
-
vi.useFakeTimers();
|
|
153
|
-
try {
|
|
154
|
-
const cwd = makeTempDir("probe-timeout-");
|
|
155
|
-
// spawner 模拟 cw --version 卡死:永不 resolve,signal abort 时 reject(走 probe catch 路径)。
|
|
156
|
-
const spawner: CwSpawner = vi.fn((_args, _input, _cwd, signal): Promise<CwSpawnResult> =>
|
|
157
|
-
new Promise<CwSpawnResult>((_resolve, reject) => {
|
|
158
|
-
signal?.addEventListener("abort", () => reject(new Error("aborted by timeout")), {
|
|
159
|
-
once: true,
|
|
160
|
-
});
|
|
161
|
-
}),
|
|
162
|
-
);
|
|
163
|
-
const probePromise = probeCwCliNormalization(spawner, cwd);
|
|
164
|
-
await vi.advanceTimersByTimeAsync(5001);
|
|
165
|
-
const cap = await probePromise;
|
|
166
|
-
expect(cap.supported).toBe(false);
|
|
167
|
-
} finally {
|
|
168
|
-
vi.useRealTimers();
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("memoize:同 cwd 第二次不调 spawner(进程内缓存)", async () => {
|
|
173
|
-
const cwd = makeTempDir("probe-memo-");
|
|
174
|
-
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
175
|
-
await probeCwCliNormalization(spawner, cwd);
|
|
176
|
-
await probeCwCliNormalization(spawner, cwd);
|
|
177
|
-
expect(calls.length).toBe(1);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("memoize:全局单值缓存,不同 cwd 也命中(cw 版本 cwd 无关,S-1)", async () => {
|
|
181
|
-
const cwdA = makeTempDir("probe-memo-a-");
|
|
182
|
-
const cwdB = makeTempDir("probe-memo-b-");
|
|
183
|
-
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
184
|
-
await probeCwCliNormalization(spawner, cwdA);
|
|
185
|
-
await probeCwCliNormalization(spawner, cwdB);
|
|
186
|
-
expect(calls.length).toBe(1);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// ── executeCwAction workspace 门控(TC1-5)──────────────────────
|
|
191
|
-
|
|
192
|
-
describe("executeCwAction workspace 门控", () => {
|
|
193
|
-
it("TC1: probe 支持 → write action 不传 --workspace(纯封装)", async () => {
|
|
194
|
-
const base = makeTempDir("gate-tc1-");
|
|
195
|
-
const repo = createGitRepo(base, "repo");
|
|
196
|
-
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
197
|
-
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
198
|
-
expect(actionCall(calls).args).not.toContain("--workspace");
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("TC2: probe 不支持 → write action 兜底传 --workspace(cw-cli ADR-0014 行为)", async () => {
|
|
202
|
-
const base = makeTempDir("gate-tc2-");
|
|
203
|
-
const repo = createGitRepo(base, "repo");
|
|
204
|
-
const { spawner, calls } = gateSpawner({ versionStdout: "cw 1.6.1" });
|
|
205
|
-
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
206
|
-
const args = actionCall(calls).args;
|
|
207
|
-
expect(args).toContain("--workspace");
|
|
208
|
-
expect(args[args.indexOf("--workspace") + 1]).toBe(repo);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("TC3: probe 失败(spawn error)→ fail-safe 兜底传 --workspace", async () => {
|
|
212
|
-
const base = makeTempDir("gate-tc3-");
|
|
213
|
-
const repo = createGitRepo(base, "repo");
|
|
214
|
-
const { spawner, calls } = gateSpawner({ versionThrow: true });
|
|
215
|
-
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
216
|
-
expect(actionCall(calls).args).toContain("--workspace");
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("TC4: 只读 action(status)始终不传 --workspace(不 probe)", async () => {
|
|
220
|
-
const base = makeTempDir("gate-tc4-");
|
|
221
|
-
const repo = createGitRepo(base, "repo");
|
|
222
|
-
const { spawner, calls } = gateSpawner({ versionStdout: "cw 99.9.9" });
|
|
223
|
-
await executeCwAction("status", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
224
|
-
// 只读不 probe:仅 1 次调用(action),且不含 --workspace
|
|
225
|
-
expect(calls.length).toBe(1);
|
|
226
|
-
expect(calls[0].args).not.toContain("--workspace");
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("TC5: 非 git 目录 + probe 不支持 → detectRepoWorkspace undefined → 不传", async () => {
|
|
230
|
-
const plain = makeTempDir("gate-tc5-plain-");
|
|
231
|
-
const { spawner, calls } = gateSpawner({ versionStdout: "cw 1.6.1" });
|
|
232
|
-
await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, plain);
|
|
233
|
-
expect(actionCall(calls).args).not.toContain("--workspace");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("TC6: 非 git 目录 + probe 不支持 + action 失败(exitCode 1)→ error 含升级指引(degradedNoWorkspace 分支回归)", async () => {
|
|
237
|
-
const plain = makeTempDir("gate-tc6-plain-");
|
|
238
|
-
const { spawner } = gateSpawner({
|
|
239
|
-
versionStdout: "cw 1.6.1",
|
|
240
|
-
actionResult: { stdout: "", stderr: "boom", exitCode: 1 },
|
|
241
|
-
});
|
|
242
|
-
const result = await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, plain);
|
|
243
|
-
expect(result.ok).toBe(false);
|
|
244
|
-
// 升级指引文案锚点(准则 6:错误指向恢复动作)
|
|
245
|
-
expect(result.error).toContain("cw-cli 版本过低");
|
|
246
|
-
expect(result.error).toContain("@zhushanwen/coding-workflow@latest");
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("TC7: git 目录 + probe 不支持 + action 失败 → error 不含升级指引(非降级路径不追加)", async () => {
|
|
250
|
-
const base = makeTempDir("gate-tc7-");
|
|
251
|
-
const repo = createGitRepo(base, "repo");
|
|
252
|
-
const { spawner } = gateSpawner({
|
|
253
|
-
versionStdout: "cw 1.6.1",
|
|
254
|
-
actionResult: { stdout: "", stderr: "boom", exitCode: 1 },
|
|
255
|
-
});
|
|
256
|
-
const result = await executeCwAction("execute", DEV_ALLOWED, "cw_dev", "u1", {}, spawner, repo);
|
|
257
|
-
expect(result.ok).toBe(false);
|
|
258
|
-
expect(result.error).toContain("exit code 1");
|
|
259
|
-
expect(result.error).not.toContain("cw-cli 版本过低");
|
|
260
|
-
});
|
|
261
|
-
});
|