@zhushanwen/pi-plan 0.4.9 → 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.
@@ -1,9 +1,19 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
 
3
- import { describe, expect, it } from "vitest";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
4
9
 
5
10
  import { extractPlanSteps } from "../compact.js";
6
- import { getBuiltinTemplateDir, listTemplates, loadTemplate } from "../templates.js";
11
+ import {
12
+ formatAvailablePlans,
13
+ getBuiltinTemplateDir,
14
+ listTemplates,
15
+ loadTemplate,
16
+ } from "../templates.js";
7
17
 
8
18
  const BUILTIN_TEMPLATE_NAMES = [
9
19
  "feature-plan",
@@ -13,30 +23,208 @@ const BUILTIN_TEMPLATE_NAMES = [
13
23
  "implementation-plan",
14
24
  ];
15
25
 
16
- describe("Template system (builtin single source, D3)", () => {
17
- it("listTemplates returns exactly the 5 builtin templates with no source field", () => {
18
- const templates = listTemplates();
26
+ // ── tmp 脚手架(测试红线:写删目标全部 mkdtempSync 自建自删,禁触真实数据目录)──
27
+
28
+ const createdDirs: string[] = [];
29
+
30
+ function mkTmpDir(prefix: string): string {
31
+ const dir = fs.mkdtempSync(join(tmpdir(), prefix));
32
+ createdDirs.push(dir);
33
+ return dir;
34
+ }
35
+
36
+ /** 在 dir 下写一个模板 md(返回绝对路径;目录按需创建) */
37
+ function writeTemplate(dir: string, file: string, content = "# skeleton\n\n## Implementation Steps\n"): string {
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const full = join(dir, file);
40
+ fs.writeFileSync(full, content);
41
+ return full;
42
+ }
43
+
44
+ /** 指向 tmp 命名空间但永不创建的路径——existsSync 防御跳过该源(隔离默认 ~/.agents/plans) */
45
+ function absentDir(label: string): string {
46
+ return join(tmpdir(), `plan-absent-${label}-${randomUUID()}`);
47
+ }
48
+
49
+ /** loadTemplate 用隔离源(用户级缺位 → 只剩内置源,不受运行机真实 ~/.agents/plans 干扰) */
50
+ function isolatedSources(): { userPlansDir: string } {
51
+ return { userPlansDir: absentDir("user") };
52
+ }
53
+
54
+ afterEach(() => {
55
+ for (const dir of createdDirs.splice(0)) {
56
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
57
+ }
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ describe("builtin source(双形态探测零改动沿用)", () => {
62
+ it("getBuiltinTemplateDir returns valid path", () => {
63
+ const dir = getBuiltinTemplateDir();
64
+ expect(fs.existsSync(dir)).toBe(true);
65
+ });
66
+
67
+ it("目录入参注入 tmp 隔离后恰为内置 5 条(TemplateInfo 双字段形态,无 source 等多余字段)", () => {
68
+ const templates = listTemplates({ userPlansDir: absentDir("user"), projectRoot: absentDir("proj") });
19
69
  expect(templates.map((t) => t.name).sort()).toEqual([...BUILTIN_TEMPLATE_NAMES].sort());
20
- // 单源化后 TemplateInfo 只有 name/path —— source 等多源残留字段即红
21
70
  for (const t of templates) {
22
71
  expect(t).toEqual({ name: t.name, path: t.path });
23
72
  }
24
73
  });
25
74
 
26
- it("loadTemplate returns content for existing builtin template", () => {
27
- const content = loadTemplate("feature-plan");
75
+ it("无参调用(默认路径内部推导)恒含内置 5 名", () => {
76
+ // 默认用户源 = 运行机真实 ~/.agents/plans(可能存在),只断言内置 5 名恒在、
77
+ // 调用不炸;全集断言必须在外部目录受控前提下才成立(见上一用例)
78
+ const names = listTemplates().map((t) => t.name);
79
+ for (const n of BUILTIN_TEMPLATE_NAMES) {
80
+ expect(names).toContain(n);
81
+ }
82
+ });
83
+ });
84
+
85
+ describe("三源合并发现器(D2/D4)", () => {
86
+ it("三源目录入参注入:内置 + 用户 + 项目全出现且 location 指向各自路径;非 .md 不进清单", () => {
87
+ const builtinDir = mkTmpDir("plan-3s-builtin-");
88
+ writeTemplate(builtinDir, "alpha.md");
89
+ const userDir = mkTmpDir("plan-3s-user-");
90
+ writeTemplate(userDir, "beta.md");
91
+ fs.writeFileSync(join(userDir, "notes.txt"), "not a template");
92
+ const projectRoot = mkTmpDir("plan-3s-project-");
93
+ const projectPlanPath = writeTemplate(join(projectRoot, ".agents", "plans"), "gamma.md");
94
+
95
+ const templates = listTemplates({ builtinDir, userPlansDir: userDir, projectRoot });
96
+ expect(templates.map((t) => t.name).sort()).toEqual(["alpha", "beta", "gamma"]);
97
+ expect(templates.find((t) => t.name === "gamma")?.path).toBe(projectPlanPath);
98
+ });
99
+
100
+ it("同名遮蔽 last-writer-wins:项目 > 用户 > 内置(同名只出现一次,location 指向胜者)", () => {
101
+ const builtinDir = mkTmpDir("plan-sh-builtin-");
102
+ writeTemplate(builtinDir, "shared.md");
103
+ const userDir = mkTmpDir("plan-sh-user-");
104
+ const userShared = writeTemplate(userDir, "shared.md");
105
+ const projectRoot = mkTmpDir("plan-sh-project-");
106
+ const projectShared = writeTemplate(join(projectRoot, ".agents", "plans"), "shared.md");
107
+
108
+ // 项目级胜
109
+ let templates = listTemplates({ builtinDir, userPlansDir: userDir, projectRoot });
110
+ expect(templates.filter((t) => t.name === "shared")).toHaveLength(1);
111
+ expect(templates.find((t) => t.name === "shared")?.path).toBe(projectShared);
112
+
113
+ // 撤项目级 → 用户级胜
114
+ fs.rmSync(projectShared);
115
+ templates = listTemplates({ builtinDir, userPlansDir: userDir, projectRoot });
116
+ expect(templates.find((t) => t.name === "shared")?.path).toBe(userShared);
117
+
118
+ // 撤用户级 → 内置胜
119
+ fs.rmSync(userShared);
120
+ templates = listTemplates({ builtinDir, userPlansDir: userDir, projectRoot });
121
+ expect(templates.find((t) => t.name === "shared")?.path).toBe(join(builtinDir, "shared.md"));
122
+ });
123
+
124
+ it("目录不存在或为空 → 该源静默跳过,合并清单为空不抛错", () => {
125
+ const templates = listTemplates({
126
+ builtinDir: mkTmpDir("plan-empty-builtin-"),
127
+ userPlansDir: absentDir("user"),
128
+ projectRoot: absentDir("proj"),
129
+ });
130
+ expect(templates).toEqual([]);
131
+ });
132
+
133
+ it("existsSync 通过但 readdir 失败(权限/TOCTOU,U3)→ 该源降级为空清单 + warn,不向上抛(进入链半进入态防御)", () => {
134
+ // 目录位放普通文件:existsSync 恒真、readdirSync 恒炸(ENOTDIR)——确定性
135
+ // 复现「存在性防御通过后枚举失败」,零 mock;命令层进入顺序 persist →
136
+ // setActiveTools → buildPlanModePrompt,此处 throw 会留半进入态
137
+ const warnSpy = vi.spyOn(getLogger("pi-plan"), "warn");
138
+ const builtinDir = mkTmpDir("plan-scan-ok-");
139
+ writeTemplate(builtinDir, "fine.md");
140
+ const fileAsDir = join(mkTmpDir("plan-scan-filedir-"), "file.md");
141
+ fs.writeFileSync(fileAsDir, "not a directory");
142
+
143
+ const templates = listTemplates({ builtinDir, userPlansDir: fileAsDir });
144
+ expect(templates.map((t) => t.name)).toEqual(["fine"]);
145
+ expect(warnSpy).toHaveBeenCalledTimes(1);
146
+ expect(warnSpy.mock.calls[0]?.[1]).toMatchObject({ dir: fileAsDir });
147
+ });
148
+ });
149
+
150
+ describe("空发现 warn(bundle 布局事故信号)", () => {
151
+ it("内置源扫描为空落 warn(即使外部源有模板遮蔽信号也照落),data 携带探测目录", () => {
152
+ const warnSpy = vi.spyOn(getLogger("pi-plan"), "warn");
153
+ const emptyBuiltin = mkTmpDir("plan-warn-empty-");
154
+ const userDir = mkTmpDir("plan-warn-user-");
155
+ writeTemplate(userDir, "only-user.md");
156
+
157
+ const templates = listTemplates({ builtinDir: emptyBuiltin, userPlansDir: userDir });
158
+ expect(templates.map((t) => t.name)).toEqual(["only-user"]);
159
+ expect(warnSpy).toHaveBeenCalledTimes(1);
160
+ expect(warnSpy.mock.calls[0]?.[1]).toEqual({ builtinDir: emptyBuiltin });
161
+ });
162
+
163
+ it("内置源非空不 warn", () => {
164
+ const warnSpy = vi.spyOn(getLogger("pi-plan"), "warn");
165
+ const builtinDir = mkTmpDir("plan-warn-ok-");
166
+ writeTemplate(builtinDir, "fine.md");
167
+
168
+ listTemplates({ builtinDir, userPlansDir: absentDir("user") });
169
+ expect(warnSpy).not.toHaveBeenCalled();
170
+ });
171
+ });
172
+
173
+ describe("loadTemplate(发现视图的读取侧)", () => {
174
+ it("returns content for existing builtin template(隔离用户源)", () => {
175
+ const content = loadTemplate("feature-plan", isolatedSources());
28
176
  expect(content).not.toBeNull();
29
177
  expect(content).toContain("## ");
30
178
  });
31
179
 
32
- it("loadTemplate returns null for non-existent template", () => {
33
- const content = loadTemplate("non-existent-template");
34
- expect(content).toBeNull();
180
+ it("returns null for non-existent template", () => {
181
+ expect(loadTemplate("non-existent-template", isolatedSources())).toBeNull();
35
182
  });
36
183
 
37
- it("getBuiltinTemplateDir returns valid path", () => {
38
- const dir = getBuiltinTemplateDir();
39
- expect(fs.existsSync(dir)).toBe(true);
184
+ it("从合并视图解析:同名时用户级胜者内容被加载", () => {
185
+ const userDir = mkTmpDir("plan-lt-user-");
186
+ writeTemplate(userDir, "feature-plan.md", "# user-owned skeleton\n");
187
+ expect(loadTemplate("feature-plan", { userPlansDir: userDir })).toBe("# user-owned skeleton\n");
188
+ });
189
+ });
190
+
191
+ describe("<available-plans> 段拼装纯函数(D6)", () => {
192
+ it("空清单返回空串(不注入段)", () => {
193
+ expect(formatAvailablePlans([])).toBe("");
194
+ });
195
+
196
+ it("条目 name+location 两字段 + guide 行,name 码点序('Alpha' < 'zeta')", () => {
197
+ const section = formatAvailablePlans([
198
+ { name: "zeta", path: "/plans/zeta.md" },
199
+ { name: "Alpha", path: "/plans/Alpha.md" },
200
+ ]);
201
+ expect(section).toBe(
202
+ "<available-plans>\n" +
203
+ "Pick a template from this list, then call plan(action='select-template', templateName='<name>').\n" +
204
+ " <plan><name>Alpha</name><location>/plans/Alpha.md</location></plan>\n" +
205
+ " <plan><name>zeta</name><location>/plans/zeta.md</location></plan>\n" +
206
+ "</available-plans>",
207
+ );
208
+ });
209
+
210
+ it("码点序而非 locale 序('-'(0x2D) < 'b'(0x62),'z'(0x7A) < 'é'(0xE9))", () => {
211
+ const section = formatAvailablePlans([
212
+ { name: "ab", path: "/1" },
213
+ { name: "a-c", path: "/2" },
214
+ { name: "é", path: "/3" },
215
+ { name: "z", path: "/4" },
216
+ ]);
217
+ // guide 行里的 '<name>' 占位符无闭合标签,正则只命中真实条目
218
+ const order = [...section.matchAll(/<name>([^<]*)<\/name>/g)].map((m) => m[1]);
219
+ expect(order).toEqual(["a-c", "ab", "z", "é"]);
220
+ });
221
+
222
+ it("全字段 escapeXml(name 与 location 均转义)", () => {
223
+ const section = formatAvailablePlans([
224
+ { name: 'tag<&>"\'plan', path: '/tmp/a&b<c>"d\'.md' },
225
+ ]);
226
+ expect(section).toContain("<name>tag&lt;&amp;&gt;&quot;&apos;plan</name>");
227
+ expect(section).toContain("<location>/tmp/a&amp;b&lt;c&gt;&quot;d&apos;.md</location>");
40
228
  });
41
229
  });
42
230
 
@@ -45,7 +233,7 @@ describe("template ↔ extractPlanSteps alignment guard (D4)", () => {
45
233
  // ① 模板标题改名 → 恰一节断言失败;
46
234
  // ② 解析正则与标题脱节 → 提取退化到 fallback 收进其他节的噪音项 → toEqual 失败。
47
235
  it.each(BUILTIN_TEMPLATE_NAMES)("template '%s' has exactly one '## Implementation Steps' section that extractPlanSteps consumes", (name) => {
48
- const content = loadTemplate(name);
236
+ const content = loadTemplate(name, isolatedSources());
49
237
  expect(content).not.toBeNull();
50
238
 
51
239
  expect(content!.match(/^## Implementation Steps$/gm)).toHaveLength(1);