pi-shepherd 0.1.0 → 0.1.2

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 (48) hide show
  1. package/README.en.md +2 -0
  2. package/README.md +4 -0
  3. package/index.ts +1 -1
  4. package/package.json +11 -5
  5. package/rules.json +22 -206
  6. package/shepherd/index.ts +1 -0
  7. package/shepherd/rules-editor.ts +80 -0
  8. package/shepherd/rules-tool-helpers.ts +120 -0
  9. package/shepherd/rules-tool-list.ts +126 -0
  10. package/shepherd/rules-tool.ts +74 -31
  11. package/shepherd/rules.ts +15 -3
  12. package/shepherd/tool-hooks.ts +3 -2
  13. package/node_modules/@pi-atelier/shared-utils/README.en.md +0 -182
  14. package/node_modules/@pi-atelier/shared-utils/README.md +0 -182
  15. package/node_modules/@pi-atelier/shared-utils/package.json +0 -51
  16. package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +0 -120
  17. package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +0 -100
  18. package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +0 -152
  19. package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +0 -187
  20. package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +0 -170
  21. package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +0 -126
  22. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +0 -138
  23. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +0 -257
  24. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +0 -189
  25. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +0 -204
  26. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +0 -269
  27. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +0 -267
  28. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +0 -520
  29. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +0 -116
  30. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +0 -119
  31. package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +0 -145
  32. package/node_modules/@pi-atelier/shared-utils/src/agents.ts +0 -39
  33. package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +0 -42
  34. package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +0 -62
  35. package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +0 -100
  36. package/node_modules/@pi-atelier/shared-utils/src/index.ts +0 -71
  37. package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +0 -96
  38. package/node_modules/@pi-atelier/shared-utils/src/paths.ts +0 -23
  39. package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +0 -241
  40. package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +0 -191
  41. package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +0 -73
  42. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +0 -104
  43. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +0 -75
  44. package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +0 -172
  45. package/node_modules/@pi-atelier/shared-utils/src/settings.ts +0 -104
  46. package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +0 -149
  47. package/node_modules/@pi-atelier/shared-utils/tsconfig.json +0 -9
  48. package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +0 -24
@@ -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
- });
@@ -1,138 +0,0 @@
1
- /**
2
- * 项目级配置边界情况测试
3
- * - 文件损坏/空配置/未知字段
4
- * - 不同项目目录隔离性
5
- * - 值相同时不报冲突
6
- * - 类型不匹配的各种情况
7
- */
8
-
9
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { join } from "node:path";
12
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
- import {
14
- clearProjectSettingsCache,
15
- detectConfigConflicts,
16
- getEffectiveConfig,
17
- validateConfigSchema,
18
- } from "../project-config";
19
-
20
- const TEST_DIR = join(tmpdir(), "pi-shared-utils-edge-test");
21
-
22
- function createProjectSettings(dir: string, content: Record<string, any>) {
23
- const piDir = join(dir, ".pi");
24
- mkdirSync(piDir, { recursive: true });
25
- writeFileSync(join(piDir, "settings.json"), JSON.stringify(content, null, 2));
26
- }
27
-
28
- function cleanupDir(dir: string) {
29
- if (existsSync(dir)) rmSync(dir, { recursive: true });
30
- }
31
-
32
- const SETTINGS_PATH = join(require("node:os").homedir(), ".pi/agent/settings.json");
33
-
34
- function mockGlobalSettings(content: Record<string, any>) {
35
- const original = readFileSync(SETTINGS_PATH, "utf-8");
36
- writeFileSync(SETTINGS_PATH, JSON.stringify(content, null, 2));
37
- return () => writeFileSync(SETTINGS_PATH, original);
38
- }
39
-
40
- let restoreGlobal: (() => void) | undefined;
41
-
42
- describe("边界情况与隔离性", () => {
43
- beforeEach(() => {
44
- cleanupDir(TEST_DIR);
45
- clearProjectSettingsCache();
46
- mkdirSync(TEST_DIR, { recursive: true });
47
- restoreGlobal = mockGlobalSettings({});
48
- });
49
- afterEach(() => {
50
- restoreGlobal?.();
51
- cleanupDir(TEST_DIR);
52
- });
53
-
54
- it("项目配置为空对象时不影响默认值", () => {
55
- createProjectSettings(TEST_DIR, {
56
- context: {},
57
- });
58
-
59
- const defaults = { distillThreshold: 5000, agingThreshold: 10 };
60
- const result = getEffectiveConfig("context", defaults, TEST_DIR);
61
- expect(typeof result.config.distillThreshold).toBe("number");
62
- expect(typeof result.config.agingThreshold).toBe("number");
63
- });
64
-
65
- it("项目配置文件损坏(无效 JSON)时回退到默认值", () => {
66
- const piDir = join(TEST_DIR, ".pi");
67
- mkdirSync(piDir, { recursive: true });
68
- writeFileSync(join(piDir, "settings.json"), "{ invalid json ");
69
-
70
- const defaults = { distillThreshold: 5000 };
71
- const result = getEffectiveConfig("context", defaults, TEST_DIR);
72
- expect(result.config.distillThreshold).toBe(5000);
73
- });
74
-
75
- it("项目配置含未知字段时保留(不丢弃)", () => {
76
- createProjectSettings(TEST_DIR, {
77
- context: { distillThreshold: 8000, unknownField: "hello" },
78
- });
79
-
80
- const defaults = { distillThreshold: 5000, agingThreshold: 10 };
81
- const result = getEffectiveConfig("context", defaults, TEST_DIR);
82
- expect(result.config.distillThreshold).toBe(8000);
83
- expect(typeof result.config.agingThreshold).toBe("number");
84
- expect((result.config as any).unknownField).toBe("hello");
85
- });
86
-
87
- it("不同项目目录的配置互不影响", () => {
88
- const dirA = join(TEST_DIR, "project-a");
89
- const dirB = join(TEST_DIR, "project-b");
90
- mkdirSync(dirA, { recursive: true });
91
- mkdirSync(dirB, { recursive: true });
92
-
93
- createProjectSettings(dirA, { context: { distillThreshold: 1000 } });
94
- createProjectSettings(dirB, { context: { distillThreshold: 2000 } });
95
-
96
- const defaults = { distillThreshold: 5000 };
97
- const resultA = getEffectiveConfig("context", defaults, dirA);
98
- const resultB = getEffectiveConfig("context", defaults, dirB);
99
-
100
- expect(resultA.config.distillThreshold).toBe(1000);
101
- expect(resultB.config.distillThreshold).toBe(2000);
102
- });
103
-
104
- it("项目值与全局值相同时不产生冲突", () => {
105
- const defaults = { distillThreshold: 5000 };
106
- const globalResult = getEffectiveConfig("context", defaults, TEST_DIR);
107
-
108
- createProjectSettings(TEST_DIR, {
109
- context: { distillThreshold: globalResult.config.distillThreshold },
110
- });
111
-
112
- const conflicts = detectConfigConflicts(TEST_DIR);
113
- const contextConflicts = conflicts.filter((c) => c.section === "context" && c.key === "distillThreshold");
114
- expect(contextConflicts).toHaveLength(0);
115
- });
116
-
117
- it("validateConfigSchema 忽略 defaults 中不存在的字段", () => {
118
- createProjectSettings(TEST_DIR, {
119
- context: { distillThreshold: 8000, extraField: "surprise" },
120
- });
121
-
122
- const errors = validateConfigSchema("context", { distillThreshold: 5000 }, TEST_DIR);
123
- expect(errors).toEqual([]);
124
- });
125
-
126
- it("数组字段类型不匹配时报错", () => {
127
- createProjectSettings(TEST_DIR, {
128
- context: { tags: "not-an-array" },
129
- });
130
-
131
- const defaults = { tags: ["default"] };
132
- const errors = validateConfigSchema("context", defaults, TEST_DIR);
133
- expect(errors.length).toBeGreaterThan(0);
134
- expect(errors[0].key).toBe("tags");
135
- expect(errors[0].expectedType).toBe("array");
136
- expect(errors[0].actualType).toBe("string");
137
- });
138
- });