pi-shepherd 0.1.1 → 0.2.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.
Files changed (58) hide show
  1. package/README.en.md +206 -83
  2. package/README.md +209 -88
  3. package/index.ts +262 -229
  4. package/package.json +60 -58
  5. package/rules.json +197 -218
  6. package/shepherd/compaction.ts +76 -0
  7. package/shepherd/conditions.ts +98 -0
  8. package/shepherd/ephemeral.ts +55 -52
  9. package/shepherd/git.ts +64 -0
  10. package/shepherd/index.ts +14 -0
  11. package/shepherd/line-count.ts +86 -86
  12. package/shepherd/message-end.ts +120 -0
  13. package/shepherd/rules-editor.ts +250 -135
  14. package/shepherd/rules-tool-helpers.ts +119 -0
  15. package/shepherd/rules-tool-list.ts +124 -0
  16. package/shepherd/rules-tool.ts +182 -99
  17. package/shepherd/rules-validate.ts +89 -44
  18. package/shepherd/rules.ts +366 -283
  19. package/shepherd/tool-event-types.ts +27 -14
  20. package/shepherd/tool-hooks.ts +165 -176
  21. package/shepherd/worktree-check.ts +130 -130
  22. package/tsconfig.json +21 -14
  23. package/node_modules/@pi-atelier/shared-utils/README.en.md +0 -182
  24. package/node_modules/@pi-atelier/shared-utils/README.md +0 -182
  25. package/node_modules/@pi-atelier/shared-utils/package.json +0 -51
  26. package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +0 -120
  27. package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +0 -100
  28. package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +0 -152
  29. package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +0 -187
  30. package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +0 -170
  31. package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +0 -126
  32. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +0 -138
  33. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +0 -257
  34. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +0 -189
  35. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +0 -204
  36. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +0 -269
  37. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +0 -267
  38. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +0 -520
  39. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +0 -116
  40. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +0 -119
  41. package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +0 -145
  42. package/node_modules/@pi-atelier/shared-utils/src/agents.ts +0 -39
  43. package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +0 -42
  44. package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +0 -62
  45. package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +0 -100
  46. package/node_modules/@pi-atelier/shared-utils/src/index.ts +0 -71
  47. package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +0 -96
  48. package/node_modules/@pi-atelier/shared-utils/src/paths.ts +0 -23
  49. package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +0 -241
  50. package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +0 -191
  51. package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +0 -73
  52. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +0 -104
  53. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +0 -75
  54. package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +0 -172
  55. package/node_modules/@pi-atelier/shared-utils/src/settings.ts +0 -104
  56. package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +0 -149
  57. package/node_modules/@pi-atelier/shared-utils/tsconfig.json +0 -9
  58. package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +0 -24
@@ -1,152 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- // ── Mock fs ─────────────────────────────────────────────
4
- // vi.hoisted ensures variables are available before vi.mock factory runs
5
-
6
- const { mockMkdirSync, mockRmSync, mockStatSync } = vi.hoisted(() => ({
7
- mockMkdirSync: vi.fn(),
8
- mockRmSync: vi.fn(),
9
- mockStatSync: vi.fn(),
10
- }));
11
-
12
- vi.mock("node:fs", () => ({
13
- mkdirSync: mockMkdirSync,
14
- rmSync: mockRmSync,
15
- statSync: mockStatSync,
16
- }));
17
-
18
- import { acquireLock, releaseLock, withFileLock } from "../file-lock";
19
-
20
- const EEXIST_ERROR = Object.assign(new Error("EEXIST"), { code: "EEXIST" });
21
- const UNKNOWN_ERROR = new Error("unknown");
22
-
23
- function makeRecentStat(): { mtimeMs: number } {
24
- return { mtimeMs: Date.now() };
25
- }
26
-
27
- function makeStaleStat(): { mtimeMs: number } {
28
- return { mtimeMs: 0 };
29
- }
30
-
31
- // ── Tests ───────────────────────────────────────────────
32
-
33
- beforeEach(() => {
34
- vi.clearAllMocks();
35
- });
36
-
37
- describe("acquireLock", () => {
38
- it("succeeds on first attempt", () => {
39
- mockMkdirSync.mockReturnValue(undefined);
40
- acquireLock("/tmp/test.lock");
41
- expect(mockMkdirSync).toHaveBeenCalledTimes(1);
42
- expect(mockMkdirSync).toHaveBeenCalledWith("/tmp/test.lock.lock");
43
- });
44
-
45
- it("rethrows non-EEXIST error", () => {
46
- mockMkdirSync.mockImplementation(() => {
47
- throw UNKNOWN_ERROR;
48
- });
49
- expect(() => acquireLock("/tmp/test.lock")).toThrow(UNKNOWN_ERROR);
50
- });
51
-
52
- it("handles EEXIST with stale lock, removes and retries success", () => {
53
- mockMkdirSync
54
- .mockImplementationOnce(() => {
55
- throw EEXIST_ERROR;
56
- })
57
- .mockReturnValue(undefined);
58
- mockStatSync.mockReturnValue(makeStaleStat());
59
-
60
- acquireLock("/tmp/test.lock");
61
-
62
- expect(mockMkdirSync).toHaveBeenCalledTimes(2);
63
- expect(mockStatSync).toHaveBeenCalledTimes(1);
64
- expect(mockStatSync).toHaveBeenCalledWith("/tmp/test.lock.lock");
65
- expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
66
- });
67
-
68
- it("handles EEXIST with statSync throw, retries and succeeds", () => {
69
- mockMkdirSync
70
- .mockImplementationOnce(() => {
71
- throw EEXIST_ERROR;
72
- })
73
- .mockReturnValue(undefined);
74
- mockStatSync.mockImplementation(() => {
75
- throw new Error("ENOENT");
76
- });
77
-
78
- acquireLock("/tmp/test.lock");
79
-
80
- expect(mockMkdirSync).toHaveBeenCalledTimes(2);
81
- expect(mockStatSync).toHaveBeenCalledTimes(1);
82
- });
83
-
84
- it("handles EEXIST with non-stale lock, retries and succeeds after busy-wait", () => {
85
- mockMkdirSync
86
- .mockImplementationOnce(() => {
87
- throw EEXIST_ERROR;
88
- })
89
- .mockReturnValue(undefined);
90
- mockStatSync.mockReturnValue(makeRecentStat());
91
-
92
- acquireLock("/tmp/test.lock");
93
-
94
- expect(mockMkdirSync).toHaveBeenCalledTimes(2);
95
- expect(mockStatSync).toHaveBeenCalledTimes(1);
96
- });
97
-
98
- it("times out after MAX_ATTEMPTS with EEXIST each time", () => {
99
- mockMkdirSync.mockImplementation(() => {
100
- throw EEXIST_ERROR;
101
- });
102
- mockStatSync.mockReturnValue(makeRecentStat());
103
-
104
- expect(() => acquireLock("/tmp/test.lock")).toThrow(
105
- "无法获取文件锁: /tmp/test.lock.lock(尝试 50 次后超时)",
106
- );
107
-
108
- expect(mockMkdirSync).toHaveBeenCalledTimes(50);
109
- expect(mockStatSync).toHaveBeenCalledTimes(50);
110
- expect(mockRmSync).not.toHaveBeenCalled();
111
- });
112
- });
113
-
114
- describe("releaseLock", () => {
115
- it("removes lock directory", () => {
116
- mockRmSync.mockReturnValue(undefined);
117
- releaseLock("/tmp/test.lock");
118
- expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
119
- });
120
-
121
- it("silently ignores rmSync errors", () => {
122
- mockRmSync.mockImplementation(() => {
123
- throw new Error("ENOENT");
124
- });
125
- expect(() => releaseLock("/tmp/test.lock")).not.toThrow();
126
- });
127
- });
128
-
129
- describe("withFileLock", () => {
130
- it("acquires lock, executes fn, releases lock", () => {
131
- mockMkdirSync.mockReturnValue(undefined);
132
- mockRmSync.mockReturnValue(undefined);
133
-
134
- const result = withFileLock("/tmp/test.lock", () => "done");
135
-
136
- expect(result).toBe("done");
137
- expect(mockMkdirSync).toHaveBeenCalledWith("/tmp/test.lock.lock");
138
- expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
139
- });
140
-
141
- it("releases lock even when fn throws", () => {
142
- mockMkdirSync.mockReturnValue(undefined);
143
-
144
- expect(() =>
145
- withFileLock("/tmp/test.lock", () => {
146
- throw new Error("fn failed");
147
- }),
148
- ).toThrow("fn failed");
149
-
150
- expect(mockRmSync).toHaveBeenCalledWith("/tmp/test.lock.lock", { recursive: true });
151
- });
152
- });
@@ -1,187 +0,0 @@
1
- /**
2
- * filter-match 测试 — matchToolName / matchFile / extractStringValues
3
- */
4
-
5
- import { describe, it, expect } from "vitest";
6
- import { matchToolName, matchFile, extractStringValues } from "../filter-match";
7
-
8
- // ── matchToolName ────────────────────────────────────
9
-
10
- describe("matchToolName", () => {
11
- // 精确匹配
12
- it("精确匹配:edit → edit ✅", () => {
13
- expect(matchToolName("edit", "edit")).toBe(true);
14
- });
15
- it("前缀匹配:edit → edit_something ❌(edit 是有效工具名,不前缀匹配)", () => {
16
- expect(matchToolName("edit", "edit_something")).toBe(false);
17
- });
18
- it("精确匹配:edit → write ❌", () => {
19
- expect(matchToolName("edit", "write")).toBe(false);
20
- });
21
-
22
- // 通配符前缀匹配(显式 *)
23
- it("通配符前缀:code_graph* → code_graph_project_map ✅", () => {
24
- expect(matchToolName("code_graph*", "code_graph_project_map")).toBe(true);
25
- });
26
- it("通配符前缀:code_graph* → code_graph_module_overview ✅", () => {
27
- expect(matchToolName("code_graph*", "code_graph_module_overview")).toBe(true);
28
- });
29
- it("通配符前缀:code_graph* → code_graph ✅(* 匹配空串)", () => {
30
- expect(matchToolName("code_graph*", "code_graph")).toBe(true);
31
- });
32
- it("通配符前缀:session* → session_analyze ✅", () => {
33
- expect(matchToolName("session*", "session_analyze")).toBe(true);
34
- });
35
- it("通配符前缀:session* → session_search ✅", () => {
36
- expect(matchToolName("session*", "session_search")).toBe(true);
37
- });
38
- it("前缀不匹配:code_graph → bash ❌", () => {
39
- expect(matchToolName("code_graph", "bash")).toBe(false);
40
- });
41
- it("前缀匹配:code_graph → code_graphx ❌(code_graph 不是前缀)", () => {
42
- expect(matchToolName("code_graphx", "code_graph")).toBe(false);
43
- });
44
-
45
- // 裸前缀只适用于“非工具名”的输入(本身不是一个完整工具名的输入)
46
- // 实际场景中用户传 "code_graph" 想匹配所有 code_graph_*
47
- // 但 "edit" 是一个真实工具名,不该前缀匹配
48
- // 所以:去掉裸前缀匹配,统一用通配符
49
- // "code_graph*" → 匹配 code_graph_project_map
50
- // "code_graph" → 只匹配 code_graph(精确)
51
-
52
- // 通配符 *
53
- it("通配符:code_graph_* → code_graph_project_map ✅", () => {
54
- expect(matchToolName("code_graph_*", "code_graph_project_map")).toBe(true);
55
- });
56
- it("通配符:code_graph_* → code_graph_module_overview ✅", () => {
57
- expect(matchToolName("code_graph_*", "code_graph_module_overview")).toBe(true);
58
- });
59
- it("通配符:code_graph_* → code_graph ✅(_分隔后的*可匹配空串)", () => {
60
- // code_graph_* → 正则 ^code_graph_.*$ → code_graph 不匹配(没有尾部_)
61
- // 这是正确的 glob 行为
62
- expect(matchToolName("code_graph_*", "code_graph")).toBe(false);
63
- });
64
- it("通配符:code_graph_* → bash ❌", () => {
65
- expect(matchToolName("code_graph_*", "bash")).toBe(false);
66
- });
67
- it("通配符:*_overview → code_graph_module_overview ✅", () => {
68
- expect(matchToolName("*_overview", "code_graph_module_overview")).toBe(true);
69
- });
70
- it("通配符:*analysis* → session_analysis_report ✅", () => {
71
- expect(matchToolName("*analysis*", "session_analysis_report")).toBe(true);
72
- });
73
-
74
- // 多值 |
75
- it("多值:edit|write → edit ✅", () => {
76
- expect(matchToolName("edit|write", "edit")).toBe(true);
77
- });
78
- it("多值:edit|write → write ✅", () => {
79
- expect(matchToolName("edit|write", "write")).toBe(true);
80
- });
81
- it("多值:edit|write → bash ❌", () => {
82
- expect(matchToolName("edit|write", "bash")).toBe(false);
83
- });
84
- it("多值含通配符:code_graph*|bash → code_graph_project_map ✅", () => {
85
- expect(matchToolName("code_graph*|bash", "code_graph_project_map")).toBe(true);
86
- }); it("多值含通配符:code_graph*|bash → code_graph ✅(精确匹配 bash 部分)", () => {
87
- expect(matchToolName("code_graph*|bash", "code_graph")).toBe(true);
88
- });
89
- it("多值含前缀:code_graph|bash → bash ✅", () => {
90
- expect(matchToolName("code_graph|bash", "bash")).toBe(true);
91
- });
92
-
93
- // 边界
94
- it("空输入不过滤 → 返回 true", () => {
95
- expect(matchToolName("", "anything")).toBe(true);
96
- });
97
- it("undefined 输入不过滤 → 返回 true", () => {
98
- expect(matchToolName(undefined, "anything")).toBe(true);
99
- });
100
- });
101
-
102
- // ── matchFile ────────────────────────────────────────
103
-
104
- describe("matchFile", () => {
105
- const paths1 = ["src/entries-nav.ts", "src/core.ts", "tests/entries.test.ts"];
106
-
107
- // 子串匹配
108
- it("子串匹配:entries-nav → 匹配含该字符串的路径", () => {
109
- expect(matchFile("entries-nav", paths1)).toBe(true);
110
- });
111
- it("子串匹配:core.ts → 匹配", () => {
112
- expect(matchFile("core.ts", paths1)).toBe(true);
113
- });
114
- it("子串匹配:nonexist → 不匹配", () => {
115
- expect(matchFile("nonexist", paths1)).toBe(false);
116
- });
117
-
118
- // 通配符
119
- it("通配符:*.test.ts → 匹配测试文件", () => {
120
- expect(matchFile("*.test.ts", paths1)).toBe(true);
121
- });
122
- it("通配符:src/* → 匹配 src 下文件", () => {
123
- expect(matchFile("src/*", paths1)).toBe(true);
124
- });
125
- it("通配符:docs/* → 不匹配", () => {
126
- expect(matchFile("docs/*", paths1)).toBe(false);
127
- });
128
-
129
- // 多值
130
- it("多值:entries-nav|core → 匹配含任一的路径", () => {
131
- expect(matchFile("entries-nav|core", paths1)).toBe(true);
132
- });
133
- it("多值:docs|config → 不匹配", () => {
134
- expect(matchFile("docs|config", paths1)).toBe(false);
135
- });
136
-
137
- // 空路径数组
138
- it("空路径数组 → false", () => {
139
- expect(matchFile("anything", [])).toBe(false);
140
- });
141
-
142
- // 空输入不过滤
143
- it("空输入 → true(不过滤)", () => {
144
- expect(matchFile("", paths1)).toBe(true);
145
- });
146
- it("undefined 输入 → true(不过滤)", () => {
147
- expect(matchFile(undefined, paths1)).toBe(true);
148
- });
149
- });
150
-
151
- // ── extractStringValues ──────────────────────────────
152
-
153
- describe("extractStringValues", () => {
154
- it("从扁平对象提取所有 string", () => {
155
- const result = extractStringValues({ path: "/src/main.ts", cmd: "npm test", count: 42 });
156
- expect(result).toContain("/src/main.ts");
157
- expect(result).toContain("npm test");
158
- expect(result).not.toContain(42);
159
- });
160
-
161
- it("从嵌套对象递归提取", () => {
162
- const result = extractStringValues({
163
- changes: [
164
- { oldText: "foo", newText: "bar" },
165
- ],
166
- path: "/src/edit.ts",
167
- });
168
- expect(result).toContain("/src/edit.ts");
169
- expect(result).toContain("foo");
170
- expect(result).toContain("bar");
171
- });
172
-
173
- it("跳过 null/undefined/number/boolean", () => {
174
- const result = extractStringValues({ a: null, b: undefined, c: 123, d: true, e: "yes" });
175
- expect(result).toEqual(["yes"]);
176
- });
177
-
178
- it("空对象 → 空数组", () => {
179
- expect(extractStringValues({})).toEqual([]);
180
- });
181
-
182
- it("数组中的 string", () => {
183
- const result = extractStringValues({ files: ["a.ts", "b.ts"] });
184
- expect(result).toContain("a.ts");
185
- expect(result).toContain("b.ts");
186
- });
187
- });
@@ -1,170 +0,0 @@
1
- /**
2
- * memory-parser.ts 测试
3
- *
4
- * parseFileName 和 buildFileName 是纯函数,无需 mock。
5
- * scanMemoryDir 需要 mock node:fs。
6
- */
7
- import { describe, it, expect, vi, beforeEach } from "vitest";
8
-
9
- const mockFs = vi.hoisted(() => ({
10
- existsSync: vi.fn(),
11
- readdirSync: vi.fn(),
12
- readFileSync: vi.fn(),
13
- }));
14
-
15
- vi.mock("node:fs", () => mockFs);
16
-
17
- import { parseFileName, buildFileName, scanMemoryDir } from "../memory-parser";
18
-
19
- beforeEach(() => {
20
- vi.clearAllMocks();
21
- });
22
-
23
- // ── parseFileName ────────────────────────────────────────
24
-
25
- describe("parseFileName", () => {
26
- it("parses topic and keywords from formatted filename", () => {
27
- const result = parseFileName("coding_standards--编码,git,lint.md");
28
- expect(result).toEqual({ topic: "coding_standards", keywords: ["编码", "git", "lint"] });
29
- });
30
-
31
- it("returns topic only when no -- separator", () => {
32
- const result = parseFileName("simple_topic.md");
33
- expect(result).toEqual({ topic: "simple_topic", keywords: [] });
34
- });
35
-
36
- it("handles empty keywords after --", () => {
37
- const result = parseFileName("topic--.md");
38
- expect(result).toEqual({ topic: "topic", keywords: [] });
39
- });
40
-
41
- it("handles null input", () => {
42
- const result = parseFileName(null as unknown as string);
43
- expect(result).toEqual({ topic: "", keywords: [] });
44
- });
45
-
46
- it("handles undefined input", () => {
47
- const result = parseFileName(undefined as unknown as string);
48
- expect(result).toEqual({ topic: "", keywords: [] });
49
- });
50
-
51
- it("handles filename without .md extension", () => {
52
- const result = parseFileName("topic--kw1,kw2");
53
- expect(result).toEqual({ topic: "topic", keywords: ["kw1", "kw2"] });
54
- });
55
-
56
- it("handles single keyword", () => {
57
- const result = parseFileName("topic--kw1.md");
58
- expect(result).toEqual({ topic: "topic", keywords: ["kw1"] });
59
- });
60
-
61
- it("handles empty string", () => {
62
- const result = parseFileName("");
63
- expect(result).toEqual({ topic: "", keywords: [] });
64
- });
65
- });
66
-
67
- // ── buildFileName ────────────────────────────────────────
68
-
69
- describe("buildFileName", () => {
70
- it("builds filename with topic and keywords", () => {
71
- expect(buildFileName("coding", ["git", "lint"])).toBe("coding--git,lint.md");
72
- });
73
-
74
- it("builds filename with topic only when no keywords", () => {
75
- expect(buildFileName("simple", [])).toBe("simple.md");
76
- });
77
-
78
- it("handles single keyword", () => {
79
- expect(buildFileName("topic", ["kw1"])).toBe("topic--kw1.md");
80
- });
81
- });
82
-
83
- // ── scanMemoryDir ────────────────────────────────────────
84
-
85
- describe("scanMemoryDir", () => {
86
- it("returns empty array when directory does not exist", () => {
87
- mockFs.existsSync.mockReturnValue(false);
88
- const result = scanMemoryDir("/nonexistent", "L1");
89
- expect(result).toEqual([]);
90
- expect(mockFs.existsSync).toHaveBeenCalledWith("/nonexistent");
91
- });
92
-
93
- it("returns empty array when directory has no md files (only MEMORY.md)", () => {
94
- mockFs.existsSync.mockReturnValue(true);
95
- mockFs.readdirSync.mockReturnValue(["MEMORY.md", "readme.txt"]);
96
- const result = scanMemoryDir("/mem", "L1");
97
- expect(result).toEqual([]);
98
- });
99
-
100
- it("scans and parses memory files correctly", () => {
101
- mockFs.existsSync.mockReturnValue(true);
102
- mockFs.readdirSync.mockReturnValue([
103
- "coding--git,lint.md",
104
- "notes.md",
105
- ]);
106
- mockFs.readFileSync.mockImplementation((filePath: string) => {
107
- if (filePath.includes("coding")) {
108
- return "# 编码规范\n内容\n";
109
- }
110
- if (filePath.includes("notes")) {
111
- return "# 笔记\n行1\n行2\n行3\n";
112
- }
113
- return "";
114
- });
115
-
116
- const result = scanMemoryDir("/mem", "L1");
117
-
118
- expect(result).toHaveLength(2);
119
- // First entry: coding
120
- expect(result[0].file).toBe("coding--git,lint.md");
121
- expect(result[0].description).toBe("编码规范");
122
- expect(result[0].lines).toBe(3);
123
- expect(result[0].scope).toBe("L1");
124
- expect(result[0].topic).toBe("coding");
125
- expect(result[0].keywords).toEqual(["git", "lint"]);
126
- // Second entry: notes
127
- expect(result[1].file).toBe("notes.md");
128
- expect(result[1].description).toBe("笔记");
129
- expect(result[1].lines).toBe(5);
130
- expect(result[1].scope).toBe("L1");
131
- expect(result[1].topic).toBe("notes");
132
- expect(result[1].keywords).toEqual([]);
133
- });
134
-
135
- it("handles read error gracefully with fallback entry", () => {
136
- mockFs.existsSync.mockReturnValue(true);
137
- mockFs.readdirSync.mockReturnValue(["broken.md"]);
138
- mockFs.readFileSync.mockImplementation(() => {
139
- throw new Error("permission denied");
140
- });
141
-
142
- const result = scanMemoryDir("/mem", "L2");
143
-
144
- expect(result).toHaveLength(1);
145
- expect(result[0].file).toBe("broken.md");
146
- expect(result[0].description).toBe("(读取失败)");
147
- expect(result[0].lines).toBe(0);
148
- expect(result[0].scope).toBe("L2");
149
- });
150
-
151
- it("handles file without title line", () => {
152
- mockFs.existsSync.mockReturnValue(true);
153
- mockFs.readdirSync.mockReturnValue(["no-title.md"]);
154
- mockFs.readFileSync.mockReturnValue("plain text\nno heading");
155
-
156
- const result = scanMemoryDir("/mem", "L1");
157
- expect(result).toHaveLength(1);
158
- expect(result[0].description).toBe("");
159
- expect(result[0].lines).toBe(2);
160
- });
161
-
162
- it("sorts files alphabetically", () => {
163
- mockFs.existsSync.mockReturnValue(true);
164
- mockFs.readdirSync.mockReturnValue(["z.md", "a.md", "m.md"]);
165
- mockFs.readFileSync.mockReturnValue("# heading\n");
166
-
167
- const result = scanMemoryDir("/mem", "L1");
168
- expect(result.map((e) => e.file)).toEqual(["a.md", "m.md", "z.md"]);
169
- });
170
- });
@@ -1,126 +0,0 @@
1
- /**
2
- * paths — 共享路径常量 — 单元测试
3
- *
4
- * 测试场景:
5
- * 1) 默认 AGENT_DIR = ~/.pi/agent
6
- * 2) PI_AGENT_DIR 环境变量覆盖(动态 import)
7
- * 3) 所有路径常量正确拼接
8
- * 4) 边界:环境变量含尾部斜杠
9
- * 5) 边界:环境变量为空字符串
10
- */
11
-
12
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
-
14
- // 默认情况下的静态导入(无环境变量)
15
- import {
16
- AGENT_DIR,
17
- AGENTS_DIR,
18
- GLOBAL_RULES_PATH,
19
- MCP_CACHE_PATH,
20
- MCP_CONFIG_PATH,
21
- MEMORY_DIR,
22
- MEMORY_MD_PATH,
23
- MODELS_CONFIG_PATH,
24
- } from "../paths";
25
-
26
- const ORIG_ENV = process.env.PI_AGENT_DIR;
27
-
28
- beforeEach(() => {
29
- delete process.env.PI_AGENT_DIR;
30
- });
31
-
32
- afterEach(() => {
33
- if (ORIG_ENV !== undefined) {
34
- process.env.PI_AGENT_DIR = ORIG_ENV;
35
- } else {
36
- delete process.env.PI_AGENT_DIR;
37
- }
38
- });
39
-
40
- // ── AGENT_DIR ─────────────────────────────────────────────
41
-
42
- describe("AGENT_DIR", () => {
43
- it("默认值为 ~/.pi/agent(无环境变量时)", () => {
44
- const expected = `${require("node:os").homedir()}/.pi/agent`;
45
- expect(AGENT_DIR).toBe(expected);
46
- });
47
-
48
- it("PI_AGENT_DIR 环境变量覆盖默认值", async () => {
49
- process.env.PI_AGENT_DIR = "/custom/pi-agent";
50
- vi.resetModules();
51
- const { AGENT_DIR: customDir } = await import("../paths");
52
- expect(customDir).toBe("/custom/pi-agent");
53
- delete process.env.PI_AGENT_DIR;
54
- vi.resetModules();
55
- });
56
-
57
- it("PI_AGENT_DIR 设为空字符串时回退到默认值", async () => {
58
- process.env.PI_AGENT_DIR = "";
59
- vi.resetModules();
60
- const { AGENT_DIR: emptyDir } = await import("../paths");
61
- const expected = `${require("node:os").homedir()}/.pi/agent`;
62
- expect(emptyDir).toBe(expected);
63
- delete process.env.PI_AGENT_DIR;
64
- vi.resetModules();
65
- });
66
-
67
- it("PI_AGENT_DIR 含尾部斜杠时不产生双斜杠", async () => {
68
- process.env.PI_AGENT_DIR = "/custom/pi-agent/";
69
- vi.resetModules();
70
- const { MODELS_CONFIG_PATH: modelPath } = await import("../paths");
71
- expect(modelPath).not.toContain("//");
72
- expect(modelPath).toBe("/custom/pi-agent/models.json");
73
- delete process.env.PI_AGENT_DIR;
74
- vi.resetModules();
75
- });
76
- });
77
-
78
- // ── 路径常量拼接正确性 ──────────────────────────────────
79
-
80
- describe("路径常量拼接", () => {
81
- const homeDir = require("node:os").homedir();
82
- const defaultAgentDir = `${homeDir}/.pi/agent`;
83
-
84
- it("MODELS_CONFIG_PATH = AGENT_DIR/models.json", () => {
85
- expect(MODELS_CONFIG_PATH).toBe(`${defaultAgentDir}/models.json`);
86
- });
87
-
88
- it("MEMORY_MD_PATH = AGENT_DIR/MEMORY.md", () => {
89
- expect(MEMORY_MD_PATH).toBe(`${defaultAgentDir}/MEMORY.md`);
90
- });
91
-
92
- it("MEMORY_DIR = AGENT_DIR/memory", () => {
93
- expect(MEMORY_DIR).toBe(`${defaultAgentDir}/memory`);
94
- });
95
-
96
- it("MCP_CONFIG_PATH = AGENT_DIR/mcp.json", () => {
97
- expect(MCP_CONFIG_PATH).toBe(`${defaultAgentDir}/mcp.json`);
98
- });
99
-
100
- it("MCP_CACHE_PATH = AGENT_DIR/mcp-cache.json", () => {
101
- expect(MCP_CACHE_PATH).toBe(`${defaultAgentDir}/mcp-cache.json`);
102
- });
103
-
104
- it("AGENTS_DIR = AGENT_DIR/agents", () => {
105
- expect(AGENTS_DIR).toBe(`${defaultAgentDir}/agents`);
106
- });
107
-
108
- it("GLOBAL_RULES_PATH = AGENT_DIR/extensions/shepherd/rules.json", () => {
109
- expect(GLOBAL_RULES_PATH).toBe(`${defaultAgentDir}/extensions/shepherd/rules.json`);
110
- });
111
- });
112
-
113
- // ── 所有路径基于相同的 AGENT_DIR ────────────────────────
114
-
115
- describe("路径一致性", () => {
116
- it("所有路径共享同一个 AGENT_DIR 前缀", () => {
117
- const based = AGENT_DIR;
118
- expect(MODELS_CONFIG_PATH.startsWith(based)).toBe(true);
119
- expect(MEMORY_MD_PATH.startsWith(based)).toBe(true);
120
- expect(MEMORY_DIR.startsWith(based)).toBe(true);
121
- expect(MCP_CONFIG_PATH.startsWith(based)).toBe(true);
122
- expect(MCP_CACHE_PATH.startsWith(based)).toBe(true);
123
- expect(AGENTS_DIR.startsWith(based)).toBe(true);
124
- expect(GLOBAL_RULES_PATH.startsWith(based)).toBe(true);
125
- });
126
- });