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,520 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
-
6
- // 测试前先 mock paths 模块的 SETTINGS_PATH
7
- const TEST_DIR = join(tmpdir(), `pi-settings-backup-test-${process.pid}`);
8
- const TEST_SETTINGS = join(TEST_DIR, "settings.json");
9
- const TEST_BACKUP_DIR = join(TEST_DIR, "settings-backup");
10
-
11
- const { patchSettingsSectionWithBackup, rollbackSettings, listBackups } = await import(
12
- "../settings-backup"
13
- );
14
-
15
- // ── 测试辅助 ────────────────────────────────────────────
16
-
17
- function writeTestSettings(content: Record<string, any>) {
18
- mkdirSync(TEST_DIR, { recursive: true });
19
- writeFileSync(TEST_SETTINGS, JSON.stringify(content, null, 2));
20
- }
21
-
22
- function readTestSettings(): Record<string, any> {
23
- if (!existsSync(TEST_SETTINGS)) return {};
24
- return JSON.parse(readFileSync(TEST_SETTINGS, "utf-8"));
25
- }
26
-
27
- function cleanupTestDir() {
28
- if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
29
- }
30
-
31
- function countBackups(): number {
32
- if (!existsSync(TEST_BACKUP_DIR)) return 0;
33
- return readdirSync(TEST_BACKUP_DIR).filter((f) => f.endsWith(".json")).length;
34
- }
35
-
36
- // ── 测试 ─────────────────────────────────────────────────
37
-
38
- describe("settings-backup", () => {
39
- beforeEach(() => {
40
- cleanupTestDir();
41
- mkdirSync(TEST_DIR, { recursive: true });
42
- });
43
- afterEach(() => {
44
- cleanupTestDir();
45
- });
46
-
47
- // ── patchSettingsSectionWithBackup ──────────────────
48
-
49
- describe("patchSettingsSectionWithBackup", () => {
50
- it("修改后自动创建备份", () => {
51
- writeTestSettings({ context: { distillThreshold: 5000 } });
52
-
53
- const result = patchSettingsSectionWithBackup(
54
- "context",
55
- { distillThreshold: 8000 },
56
- { distillThreshold: 5000, agingThreshold: 20 },
57
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
58
- );
59
-
60
- expect(result.config.distillThreshold).toBe(8000);
61
- expect(result.config.agingThreshold).toBe(20); // defaults 填充
62
- expect(existsSync(TEST_BACKUP_DIR)).toBe(true);
63
- expect(countBackups()).toBe(1);
64
-
65
- // 备份内容应该是修改前的值
66
- const backupFiles = readdirSync(TEST_BACKUP_DIR).filter((f) => f.endsWith(".json"));
67
- const backupContent = JSON.parse(readFileSync(join(TEST_BACKUP_DIR, backupFiles[0]), "utf-8"));
68
- expect(backupContent.context.distillThreshold).toBe(5000);
69
- });
70
-
71
- it("多次修改产生多个备份", async () => {
72
- writeTestSettings({ context: { distillThreshold: 5000 } });
73
-
74
- patchSettingsSectionWithBackup(
75
- "context",
76
- { distillThreshold: 6000 },
77
- { distillThreshold: 5000 },
78
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
79
- );
80
- await new Promise((r) => setTimeout(r, 10));
81
- patchSettingsSectionWithBackup(
82
- "context",
83
- { distillThreshold: 7000 },
84
- { distillThreshold: 5000 },
85
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
86
- );
87
-
88
- expect(countBackups()).toBe(2);
89
- });
90
-
91
- it("超过 maxBackups 时清理旧备份", async () => {
92
- writeTestSettings({ context: { distillThreshold: 5000 } });
93
-
94
- for (let i = 0; i < 5; i++) {
95
- patchSettingsSectionWithBackup(
96
- "context",
97
- { distillThreshold: 5000 + i },
98
- { distillThreshold: 5000 },
99
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, maxBackups: 3 },
100
- );
101
- await new Promise((r) => setTimeout(r, 10));
102
- }
103
-
104
- expect(countBackups()).toBe(3);
105
- });
106
-
107
- it("类型校验不通过时返回错误但不写入", () => {
108
- writeTestSettings({ context: { distillThreshold: 5000 } });
109
-
110
- const result = patchSettingsSectionWithBackup(
111
- "context",
112
- { distillThreshold: "wrong-type" as any },
113
- { distillThreshold: 5000 },
114
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, validate: true },
115
- );
116
-
117
- expect(result.errors.length).toBeGreaterThan(0);
118
- expect(result.errors[0].key).toBe("distillThreshold");
119
- // settings.json 不应该被修改
120
- expect(readTestSettings().context.distillThreshold).toBe(5000);
121
- });
122
-
123
- it("validate: false 跳过类型校验", () => {
124
- writeTestSettings({ context: { distillThreshold: 5000 } });
125
-
126
- const result = patchSettingsSectionWithBackup(
127
- "context",
128
- { distillThreshold: "skip-validation" as any },
129
- { distillThreshold: 5000 },
130
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, validate: false },
131
- );
132
-
133
- expect(result.errors).toEqual([]);
134
- expect(readTestSettings().context.distillThreshold).toBe("skip-validation");
135
- });
136
-
137
- it("备份目录不存在时自动创建", () => {
138
- writeTestSettings({ context: { distillThreshold: 5000 } });
139
- expect(existsSync(TEST_BACKUP_DIR)).toBe(false);
140
-
141
- patchSettingsSectionWithBackup(
142
- "context",
143
- { distillThreshold: 8000 },
144
- { distillThreshold: 5000 },
145
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
146
- );
147
-
148
- expect(existsSync(TEST_BACKUP_DIR)).toBe(true);
149
- });
150
-
151
- it("备份文件名包含时间戳", () => {
152
- writeTestSettings({ context: { distillThreshold: 5000 } });
153
-
154
- patchSettingsSectionWithBackup(
155
- "context",
156
- { distillThreshold: 8000 },
157
- { distillThreshold: 5000 },
158
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
159
- );
160
-
161
- const files = readdirSync(TEST_BACKUP_DIR).filter((f) => f.endsWith(".json"));
162
- expect(files.length).toBe(1);
163
- expect(files[0]).toMatch(/^settings\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
164
- });
165
-
166
- // ── 边缘情况 ─────────────────────────────
167
-
168
- it("settings.json 不存在时创建新文件", () => {
169
- // 不写 settings.json,让它不存在
170
- expect(existsSync(TEST_SETTINGS)).toBe(false);
171
-
172
- const result = patchSettingsSectionWithBackup(
173
- "context",
174
- { distillThreshold: 8000 },
175
- { distillThreshold: 5000 },
176
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
177
- );
178
-
179
- expect(result.config.distillThreshold).toBe(8000);
180
- expect(existsSync(TEST_SETTINGS)).toBe(true);
181
- expect(readTestSettings().context.distillThreshold).toBe(8000);
182
- // 不存在时不创建备份(没有东西可备份)
183
- expect(countBackups()).toBe(0);
184
- });
185
-
186
- it("settings.json 内容是非法 JSON 时当作空对象处理", () => {
187
- mkdirSync(TEST_DIR, { recursive: true });
188
- writeFileSync(TEST_SETTINGS, "{ broken json !!!");
189
-
190
- const result = patchSettingsSectionWithBackup(
191
- "context",
192
- { distillThreshold: 8000 },
193
- { distillThreshold: 5000 },
194
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
195
- );
196
-
197
- // 不崩溃,当作空对象处理
198
- expect(result.config.distillThreshold).toBe(8000);
199
- });
200
-
201
- it("section 不存在时新增 section", () => {
202
- writeTestSettings({ other: { foo: 1 } });
203
-
204
- const result = patchSettingsSectionWithBackup(
205
- "context",
206
- { distillThreshold: 8000 },
207
- { distillThreshold: 5000 },
208
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
209
- );
210
-
211
- expect(result.config.distillThreshold).toBe(8000);
212
- const settings = readTestSettings();
213
- expect(settings.context.distillThreshold).toBe(8000);
214
- expect(settings.other.foo).toBe(1); // 其他 section 不受影响
215
- });
216
-
217
- it("patch 值为 undefined 时跳过该 key", () => {
218
- writeTestSettings({ context: { distillThreshold: 5000, agingThreshold: 20 } });
219
-
220
- patchSettingsSectionWithBackup(
221
- "context",
222
- { distillThreshold: undefined },
223
- { distillThreshold: 5000, agingThreshold: 20 },
224
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
225
- );
226
-
227
- // undefined 不应该覆盖现有值
228
- const settings = readTestSettings();
229
- expect(settings.context.distillThreshold).toBe(5000);
230
- expect(settings.context.agingThreshold).toBe(20);
231
- });
232
-
233
- it("patch 值为 null 时被类型校验拦截(null !== number)", () => {
234
- writeTestSettings({ context: { distillThreshold: 5000 } });
235
-
236
- const result = patchSettingsSectionWithBackup(
237
- "context",
238
- { distillThreshold: null as any },
239
- { distillThreshold: 5000 },
240
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
241
- );
242
-
243
- // null 被校验拦截 — 类型不匹配(null vs number)
244
- expect(result.errors.length).toBeGreaterThan(0);
245
- expect(readTestSettings().context.distillThreshold).toBe(5000);
246
- });
247
-
248
- it("patch 值为 null 且 validate:false 时正常写入 null", () => {
249
- writeTestSettings({ context: { distillThreshold: 5000 } });
250
-
251
- patchSettingsSectionWithBackup(
252
- "context",
253
- { distillThreshold: null as any },
254
- { distillThreshold: 5000 },
255
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, validate: false },
256
- );
257
-
258
- expect(readTestSettings().context.distillThreshold).toBeNull();
259
- });
260
-
261
- it("backup: false 跳过备份创建", () => {
262
- writeTestSettings({ context: { distillThreshold: 5000 } });
263
-
264
- patchSettingsSectionWithBackup(
265
- "context",
266
- { distillThreshold: 8000 },
267
- { distillThreshold: 5000 },
268
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
269
- );
270
-
271
- expect(countBackups()).toBe(0);
272
- expect(readTestSettings().context.distillThreshold).toBe(8000);
273
- });
274
-
275
- it("空 patch 不修改配置值但正常写入", () => {
276
- writeTestSettings({ context: { distillThreshold: 5000 } });
277
-
278
- const result = patchSettingsSectionWithBackup(
279
- "context",
280
- {},
281
- { distillThreshold: 5000 },
282
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
283
- );
284
-
285
- expect(result.config.distillThreshold).toBe(5000);
286
- expect(readTestSettings().context.distillThreshold).toBe(5000);
287
- // 空修改也会备份(安全起见)
288
- expect(countBackups()).toBe(1);
289
- });
290
-
291
- it("defaults 中有嵌套对象时不深度校验", () => {
292
- writeTestSettings({});
293
-
294
- const result = patchSettingsSectionWithBackup(
295
- "mcp",
296
- { disabled: ["server-a"] },
297
- { disabled: [] },
298
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
299
- );
300
-
301
- expect(result.errors).toEqual([]);
302
- expect(result.config.disabled).toEqual(["server-a"]);
303
- });
304
-
305
- it("保留其他 section 不受影响", () => {
306
- writeTestSettings({
307
- other: { foo: 1 },
308
- context: { distillThreshold: 5000 },
309
- });
310
-
311
- patchSettingsSectionWithBackup(
312
- "context",
313
- { distillThreshold: 8000 },
314
- { distillThreshold: 5000 },
315
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
316
- );
317
-
318
- expect(readTestSettings().other.foo).toBe(1);
319
- });
320
- });
321
-
322
- // ── rollbackSettings ──────────────────────────────
323
-
324
- describe("rollbackSettings", () => {
325
- it("恢复到最近一次备份", () => {
326
- writeTestSettings({ context: { distillThreshold: 5000 } });
327
-
328
- patchSettingsSectionWithBackup(
329
- "context",
330
- { distillThreshold: 8000 },
331
- { distillThreshold: 5000 },
332
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
333
- );
334
- expect(readTestSettings().context.distillThreshold).toBe(8000);
335
-
336
- rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
337
- expect(readTestSettings().context.distillThreshold).toBe(5000);
338
- });
339
-
340
- it("无备份时抛错", () => {
341
- writeTestSettings({ context: { distillThreshold: 5000 } });
342
- expect(() => rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR })).toThrow(
343
- /无可用备份/,
344
- );
345
- });
346
-
347
- it("回滚后删除已恢复的备份", () => {
348
- writeTestSettings({ context: { distillThreshold: 5000 } });
349
-
350
- patchSettingsSectionWithBackup(
351
- "context",
352
- { distillThreshold: 8000 },
353
- { distillThreshold: 5000 },
354
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
355
- );
356
- expect(countBackups()).toBe(1);
357
-
358
- rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
359
- expect(countBackups()).toBe(0);
360
- });
361
-
362
- it("连续两次回滚(第二次无备份应抛错)", async () => {
363
- writeTestSettings({ context: { distillThreshold: 5000 } });
364
-
365
- patchSettingsSectionWithBackup(
366
- "context",
367
- { distillThreshold: 6000 },
368
- { distillThreshold: 5000 },
369
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
370
- );
371
- await new Promise((r) => setTimeout(r, 10));
372
- patchSettingsSectionWithBackup(
373
- "context",
374
- { distillThreshold: 7000 },
375
- { distillThreshold: 5000 },
376
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
377
- );
378
-
379
- // 第一次回滚:7000 → 6000
380
- rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
381
- expect(readTestSettings().context.distillThreshold).toBe(6000);
382
- expect(countBackups()).toBe(1);
383
-
384
- // 第二次回滚:6000 → 5000
385
- rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
386
- expect(readTestSettings().context.distillThreshold).toBe(5000);
387
- expect(countBackups()).toBe(0);
388
-
389
- // 第三次回滚:无备份,抛错
390
- expect(() => rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR })).toThrow();
391
- });
392
-
393
- it("回滚不影响备份目录外的文件", () => {
394
- writeTestSettings({ context: { distillThreshold: 5000 } });
395
- // 在备份目录外创建一个文件
396
- const extraFile = join(TEST_DIR, "other-file.json");
397
- writeFileSync(extraFile, '{"test": true}');
398
-
399
- patchSettingsSectionWithBackup(
400
- "context",
401
- { distillThreshold: 8000 },
402
- { distillThreshold: 5000 },
403
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
404
- );
405
-
406
- rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
407
-
408
- expect(existsSync(extraFile)).toBe(true);
409
- expect(JSON.parse(readFileSync(extraFile, "utf-8")).test).toBe(true);
410
- });
411
- });
412
-
413
- // ── listBackups ──────────────────────────────────
414
-
415
- describe("listBackups", () => {
416
- it("返回备份列表(按时间倒序)", async () => {
417
- writeTestSettings({ context: { distillThreshold: 5000 } });
418
-
419
- patchSettingsSectionWithBackup(
420
- "context",
421
- { distillThreshold: 6000 },
422
- { distillThreshold: 5000 },
423
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
424
- );
425
- await new Promise((r) => setTimeout(r, 10));
426
- patchSettingsSectionWithBackup(
427
- "context",
428
- { distillThreshold: 7000 },
429
- { distillThreshold: 5000 },
430
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
431
- );
432
-
433
- const backups = listBackups({ backupDir: TEST_BACKUP_DIR });
434
- expect(backups.length).toBe(2);
435
- // 最新的在前
436
- expect(backups[0].timestamp > backups[1].timestamp).toBe(true);
437
- });
438
-
439
- it("无备份时返回空数组", () => {
440
- const backups = listBackups({ backupDir: TEST_BACKUP_DIR });
441
- expect(backups).toEqual([]);
442
- });
443
-
444
- it("备份目录不存在时返回空数组", () => {
445
- const backups = listBackups({ backupDir: "/nonexistent/path" });
446
- expect(backups).toEqual([]);
447
- });
448
- });
449
-
450
- describe("文件锁", () => {
451
- it("加锁期间其他操作不会被覆盖", () => {
452
- cleanupTestDir();
453
- writeTestSettings({ context: { threshold: 100 }, otherSection: { keep: true } });
454
-
455
- // 模拟:在 patch 加锁期间,另一个进程写入了新字段
456
- // 这里通过 patch 两次验证加锁读-写是原子的
457
- const result1 = patchSettingsSectionWithBackup(
458
- "context",
459
- { threshold: 200 },
460
- { threshold: 100 },
461
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
462
- );
463
- const result2 = patchSettingsSectionWithBackup(
464
- "shepherd",
465
- { maxWarnings: 3 },
466
- { maxWarnings: 5 },
467
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
468
- );
469
-
470
- // 两次 patch 都成功
471
- expect(result1.config.threshold).toBe(200);
472
- expect(result2.config.maxWarnings).toBe(3);
473
-
474
- // 文件中两个 section 都保留
475
- const finalSettings = readTestSettings();
476
- expect(finalSettings.context.threshold).toBe(200);
477
- expect(finalSettings.shepherd.maxWarnings).toBe(3);
478
- expect(finalSettings.otherSection.keep).toBe(true);
479
- });
480
-
481
- it("锁文件在操作后会被清理", () => {
482
- cleanupTestDir();
483
- writeTestSettings({ context: { threshold: 100 } });
484
-
485
- patchSettingsSectionWithBackup(
486
- "context",
487
- { threshold: 200 },
488
- { threshold: 100 },
489
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
490
- );
491
-
492
- // 锁目录不应残留
493
- expect(existsSync(`${TEST_SETTINGS}.lock`)).toBe(false);
494
- });
495
-
496
- it("过时锁会被自动清理", () => {
497
- cleanupTestDir();
498
- writeTestSettings({ context: { threshold: 100 } });
499
-
500
- // 创建一个过时锁(mtime 设为 20 秒前)
501
- const lockDir = `${TEST_SETTINGS}.lock`;
502
- mkdirSync(lockDir);
503
- // 手动修改 mtime 为 20 秒前
504
- const { utimesSync } = require("node:fs");
505
- const oldTime = (Date.now() - 20_000) / 1000;
506
- utimesSync(lockDir, oldTime, oldTime);
507
-
508
- // patch 应该能清理过时锁并正常执行
509
- const result = patchSettingsSectionWithBackup(
510
- "context",
511
- { threshold: 300 },
512
- { threshold: 100 },
513
- { settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
514
- );
515
-
516
- expect(result.config.threshold).toBe(300);
517
- expect(existsSync(lockDir)).toBe(false);
518
- });
519
- });
520
- });
@@ -1,116 +0,0 @@
1
- /**
2
- * settings.ts 读取操作测试(getSettingsSection, getSettingsValue)
3
- *
4
- * 使用有状态的 fs mock:writeFileSync 更新存储区,readFileSync 读取最新内容。
5
- */
6
- import { describe, it, expect, vi, beforeEach } from "vitest";
7
-
8
- const store = vi.hoisted(() => {
9
- let content = "";
10
- return { get: () => content, set: (v: string) => { content = v; } };
11
- });
12
-
13
- const mockOs = vi.hoisted(() => ({
14
- homedir: vi.fn().mockReturnValue("/fake-home"),
15
- }));
16
- vi.mock("node:os", () => mockOs);
17
-
18
- const mockFs = vi.hoisted(() => ({
19
- existsSync: vi.fn(),
20
- readFileSync: vi.fn(() => store.get()),
21
- writeFileSync: vi.fn((_path: string, data: string) => store.set(data)),
22
- }));
23
- vi.mock("node:fs", () => mockFs);
24
-
25
- import { getSettingsSection, getSettingsValue } from "../settings";
26
-
27
- beforeEach(() => {
28
- vi.clearAllMocks();
29
- store.set("");
30
- });
31
-
32
- function mockSettings(content: Record<string, unknown>): void {
33
- store.set(JSON.stringify(content));
34
- mockFs.existsSync.mockReturnValue(true);
35
- }
36
-
37
- // ── getSettingsSection ──────────────────────────────────
38
-
39
- describe("getSettingsSection", () => {
40
- it("returns defaults when settings.json does not exist", () => {
41
- mockFs.existsSync.mockReturnValue(false);
42
- const result = getSettingsSection("context", { threshold: 5000, active: true });
43
- expect(result).toEqual({ threshold: 5000, active: true });
44
- });
45
-
46
- it("returns defaults when settings.json is empty object", () => {
47
- mockSettings({});
48
- const result = getSettingsSection("context", { threshold: 5000 });
49
- expect(result).toEqual({ threshold: 5000 });
50
- });
51
-
52
- it("merges stored values over defaults", () => {
53
- mockSettings({ context: { threshold: 8000 } });
54
- const result = getSettingsSection("context", { threshold: 5000, active: true });
55
- expect(result).toEqual({ threshold: 8000, active: true });
56
- });
57
-
58
- it("ignores stored keys not in defaults", () => {
59
- mockSettings({ context: { threshold: 8000, junk: "ignore" } });
60
- const result = getSettingsSection("context", { threshold: 5000 });
61
- expect(result).toEqual({ threshold: 8000 });
62
- });
63
-
64
- it("returns defaults when section exists but is empty", () => {
65
- mockSettings({ context: {} });
66
- const result = getSettingsSection("context", { threshold: 5000, active: true });
67
- expect(result).toEqual({ threshold: 5000, active: true });
68
- });
69
-
70
- it("returns defaults when settings.json is invalid JSON", () => {
71
- mockFs.existsSync.mockReturnValue(true);
72
- store.set("{ invalid json ");
73
- const result = getSettingsSection("context", { threshold: 5000 });
74
- expect(result).toEqual({ threshold: 5000 });
75
- });
76
-
77
- it("handles section not present in settings", () => {
78
- mockSettings({ otherSection: { key: 1 } });
79
- const result = getSettingsSection("context", { threshold: 5000 });
80
- expect(result).toEqual({ threshold: 5000 });
81
- });
82
- });
83
-
84
- // ── getSettingsValue ────────────────────────────────────
85
-
86
- describe("getSettingsValue", () => {
87
- it("returns value at simple path", () => {
88
- mockSettings({ recording: { enabled: true } });
89
- expect(getSettingsValue("recording.enabled", false)).toBe(true);
90
- });
91
-
92
- it("returns fallback when path does not exist", () => {
93
- mockSettings({});
94
- expect(getSettingsValue("recording.enabled", false)).toBe(false);
95
- });
96
-
97
- it("returns fallback when settings.json does not exist", () => {
98
- mockFs.existsSync.mockReturnValue(false);
99
- expect(getSettingsValue("some.key", "default")).toBe("default");
100
- });
101
-
102
- it("returns nested fallback for deep path", () => {
103
- mockSettings({ a: { b: {} } });
104
- expect(getSettingsValue("a.b.c.d", "fallback")).toBe("fallback");
105
- });
106
-
107
- it("returns null when value is null", () => {
108
- mockSettings({ key: null });
109
- expect(getSettingsValue("key", "fallback")).toBeNull();
110
- });
111
-
112
- it("returns fallback when intermediate key is null", () => {
113
- mockSettings({ a: null });
114
- expect(getSettingsValue("a.b", "fallback")).toBe("fallback");
115
- });
116
- });