@zhushanwen/pi-permission 0.0.1

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 (51) hide show
  1. package/README.md +455 -0
  2. package/index.ts +2 -0
  3. package/package.json +52 -0
  4. package/src/__tests__/approval.test.ts +456 -0
  5. package/src/__tests__/commands.test.ts +487 -0
  6. package/src/__tests__/config.test.ts +229 -0
  7. package/src/__tests__/e2e-modes.test.ts +253 -0
  8. package/src/__tests__/extension.test.ts +170 -0
  9. package/src/__tests__/index-integration.test.ts +282 -0
  10. package/src/__tests__/model-picker.test.ts +371 -0
  11. package/src/__tests__/pipeline.test.ts +976 -0
  12. package/src/__tests__/production.test.ts +102 -0
  13. package/src/__tests__/rule-editor-component.test.ts +475 -0
  14. package/src/__tests__/rule-editor.test.ts +252 -0
  15. package/src/__tests__/rule-templates.test.ts +264 -0
  16. package/src/__tests__/statusline.test.ts +221 -0
  17. package/src/__tests__/types.test.ts +59 -0
  18. package/src/approval.ts +352 -0
  19. package/src/ast/__tests__/analyzer.test.ts +262 -0
  20. package/src/ast/__tests__/loader.test.ts +136 -0
  21. package/src/ast/analyzer.ts +326 -0
  22. package/src/ast/index.ts +7 -0
  23. package/src/ast/loader.ts +113 -0
  24. package/src/classifier/__tests__/classifier.test.ts +285 -0
  25. package/src/classifier/__tests__/json-parser.test.ts +118 -0
  26. package/src/classifier/__tests__/model-resolver.test.ts +173 -0
  27. package/src/classifier/__tests__/prompt.test.ts +59 -0
  28. package/src/classifier/classifier.ts +242 -0
  29. package/src/classifier/index.ts +28 -0
  30. package/src/classifier/json-parser.ts +127 -0
  31. package/src/classifier/model-resolver.ts +295 -0
  32. package/src/classifier/prompt.ts +52 -0
  33. package/src/commands.ts +234 -0
  34. package/src/config.ts +216 -0
  35. package/src/index.ts +291 -0
  36. package/src/model-picker.ts +398 -0
  37. package/src/pipeline.ts +541 -0
  38. package/src/production.ts +102 -0
  39. package/src/rule-editor-component.ts +883 -0
  40. package/src/rule-editor.ts +262 -0
  41. package/src/rule-templates.ts +332 -0
  42. package/src/rules/__tests__/builtins.test.ts +614 -0
  43. package/src/rules/__tests__/index.test.ts +26 -0
  44. package/src/rules/__tests__/matcher.test.ts +378 -0
  45. package/src/rules/__tests__/wildcard.test.ts +84 -0
  46. package/src/rules/builtins.ts +680 -0
  47. package/src/rules/index.ts +27 -0
  48. package/src/rules/matcher.ts +160 -0
  49. package/src/rules/wildcard.ts +43 -0
  50. package/src/statusline.ts +226 -0
  51. package/src/types.ts +249 -0
@@ -0,0 +1,487 @@
1
+ /**
2
+ * commands.test.ts — W6 T7: handlePermissionCommand 单元测试。
3
+ *
4
+ * 覆盖 /permission 命令各参数分支:
5
+ * - 无参 → 显示菜单(当前模式 + 可用模式列表)
6
+ * - set yolo/auto/approve/strict → 切换模式
7
+ * - invalid mode → 错误提示
8
+ * - status → 详细配置
9
+ * - 当前状态(已是目标模式)→ "Already in X mode"
10
+ *
11
+ * handlePermissionCommand 是纯函数(输入 args + config + onSave,输出 string),
12
+ * 不依赖 Pi 运行时,便于单测。
13
+ */
14
+ import { describe, expect, it, vi } from "vitest";
15
+
16
+ import type { ResolvedModelEntry } from "../classifier/model-resolver.js";
17
+ import { handlePermissionCommand, handlePermissionModelCommand, handlePermissionRuleCommand, type PermissionModelCommandDeps, type PermissionRuleCommandDeps } from "../commands.js";
18
+ import type { ModelPickerContext } from "../model-picker.js";
19
+ import type { RuleEditorContext } from "../rule-editor.js";
20
+ import type { RuleOp } from "../rule-templates.js";
21
+ import { DEFAULT_CONFIG, type PermissionConfig, type Rule } from "../types.js";
22
+
23
+ // ──────────────────────── mock helpers ────────────────────────
24
+
25
+ /** 构造一个可变 config 副本(默认 yolo)。 */
26
+ function makeConfig(overrides: Partial<PermissionConfig> = {}): PermissionConfig {
27
+ return {
28
+ mode: "yolo",
29
+ enabled: true,
30
+ classifier: { enabled: true, model: "auto", timeout: 90, autoApproveLowRisk: true, autoDenyHighRisk: true },
31
+ userRules: [],
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ /** onSave mock:总是成功。 */
37
+ function successSave(): (config: PermissionConfig) => { success: boolean; error?: string } {
38
+ return vi.fn(() => ({ success: true }));
39
+ }
40
+
41
+ // ──────────────────────── 无参:显示菜单 ────────────────────────
42
+
43
+ describe("/permission 无参 → 显示菜单", () => {
44
+ it("无参显示当前模式 + 可用模式列表", () => {
45
+ const config = makeConfig({ mode: "approve" });
46
+ const msg = handlePermissionCommand(undefined, config, successSave());
47
+ expect(msg).toContain("Current mode");
48
+ expect(msg).toContain("approve");
49
+ expect(msg).toContain("Available modes");
50
+ expect(msg).toContain("yolo");
51
+ expect(msg).toContain("auto");
52
+ expect(msg).toContain("strict");
53
+ });
54
+
55
+ it("空字符串等同无参 → 显示菜单", () => {
56
+ const msg = handlePermissionCommand("", makeConfig(), successSave());
57
+ expect(msg).toContain("Current mode");
58
+ });
59
+
60
+ it("纯空白等同无参 → 显示菜单", () => {
61
+ const msg = handlePermissionCommand(" ", makeConfig(), successSave());
62
+ expect(msg).toContain("Current mode");
63
+ });
64
+
65
+ it("当前模式在列表中用 ► 标记", () => {
66
+ const msg = handlePermissionCommand(undefined, makeConfig({ mode: "auto" }), successSave());
67
+ expect(msg).toContain("► auto");
68
+ expect(msg).not.toContain("► yolo");
69
+ });
70
+ });
71
+
72
+ // ──────────────────────── status 子命令 ────────────────────────
73
+
74
+ describe("/permission status → 详细配置", () => {
75
+ it("status 显示 mode/enabled/classifier/userRules", () => {
76
+ const config = makeConfig({
77
+ mode: "strict",
78
+ classifier: { enabled: true, model: "zhipu/glm-4-flash", timeout: 30, autoApproveLowRisk: false, autoDenyHighRisk: true },
79
+ userRules: [{ id: "u1", tool: "bash", pattern: "rm *", action: "deny", source: "user" }],
80
+ });
81
+ const msg = handlePermissionCommand("status", config, successSave());
82
+ expect(msg).toContain("mode:");
83
+ expect(msg).toContain("strict");
84
+ expect(msg).toContain("enabled:");
85
+ expect(msg).toContain("classifier:");
86
+ expect(msg).toContain("zhipu/glm-4-flash");
87
+ expect(msg).toContain("userRules:");
88
+ expect(msg).toContain("1 rule(s)");
89
+ });
90
+
91
+ it("status 显示 autoApproveLowRisk=false", () => {
92
+ const config = makeConfig({
93
+ classifier: { enabled: true, model: "auto", timeout: 90, autoApproveLowRisk: false, autoDenyHighRisk: true },
94
+ });
95
+ const msg = handlePermissionCommand("status", config, successSave());
96
+ expect(msg).toContain("autoApproveLow:");
97
+ expect(msg).toContain("false");
98
+ });
99
+ });
100
+
101
+ // ──────────────────────── 切换模式 ────────────────────────
102
+
103
+ describe("/permission <mode> → 切换模式", () => {
104
+ it("set yolo(从 strict 切到 yolo)→ onSave 被调,返回切换成功", () => {
105
+ const config = makeConfig({ mode: "strict" });
106
+ const onSave = vi.fn(() => ({ success: true }));
107
+ const msg = handlePermissionCommand("yolo", config, onSave);
108
+ expect(msg).toContain("Switched to YOLO mode");
109
+ expect(msg).toContain("完全无防护");
110
+ expect(onSave).toHaveBeenCalledOnce();
111
+ // onSave 收到的新 config mode=yolo
112
+ const savedConfig = onSave.mock.calls[0]![0] as PermissionConfig;
113
+ expect(savedConfig.mode).toBe("yolo");
114
+ });
115
+
116
+ it("set auto → 切换到 auto 模式", () => {
117
+ const msg = handlePermissionCommand("auto", makeConfig({ mode: "yolo" }), successSave());
118
+ expect(msg).toContain("Switched to Auto mode");
119
+ expect(msg).toContain("安全命令规则直接放行");
120
+ });
121
+
122
+ it("set approve → 切换到 approve 模式", () => {
123
+ const msg = handlePermissionCommand("approve", makeConfig({ mode: "yolo" }), successSave());
124
+ expect(msg).toContain("Switched to Approve mode");
125
+ });
126
+
127
+ it("set strict → 切换到 strict 模式", () => {
128
+ const msg = handlePermissionCommand("strict", makeConfig({ mode: "yolo" }), successSave());
129
+ expect(msg).toContain("Switched to Strict mode");
130
+ expect(msg).toContain("全部审批");
131
+ });
132
+
133
+ it("已是目标模式 → 'Already in X mode',不调 onSave", () => {
134
+ const onSave = vi.fn(() => ({ success: true }));
135
+ const msg = handlePermissionCommand("yolo", makeConfig({ mode: "yolo" }), onSave);
136
+ expect(msg).toContain("Already in YOLO mode");
137
+ expect(onSave).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("onSave 失败 → 返回失败信息(含 error)", () => {
141
+ const onSave = vi.fn(() => ({ success: false, error: "disk full" }));
142
+ const msg = handlePermissionCommand("strict", makeConfig({ mode: "yolo" }), onSave);
143
+ expect(msg).toContain("Failed to switch");
144
+ expect(msg).toContain("disk full");
145
+ });
146
+
147
+ it("切换模式保留 enabled/classifier/userRules(仅改 mode)", () => {
148
+ const config = makeConfig({
149
+ mode: "yolo",
150
+ enabled: false,
151
+ classifier: { enabled: false, model: "custom", timeout: 10, autoApproveLowRisk: false, autoDenyHighRisk: false },
152
+ userRules: [{ id: "u1", tool: "bash", pattern: "x", action: "deny", source: "user" }],
153
+ });
154
+ const onSave = vi.fn(() => ({ success: true }));
155
+ handlePermissionCommand("strict", config, onSave);
156
+ const saved = onSave.mock.calls[0]![0] as PermissionConfig;
157
+ expect(saved.mode).toBe("strict");
158
+ expect(saved.enabled).toBe(false); // 保留
159
+ expect(saved.classifier.model).toBe("custom"); // 保留
160
+ expect(saved.classifier.autoApproveLowRisk).toBe(false); // 保留
161
+ expect(saved.userRules).toHaveLength(1); // 保留
162
+ });
163
+ });
164
+
165
+ // ──────────────────────── invalid mode ────────────────────────
166
+
167
+ describe("/permission <invalid> → 错误提示", () => {
168
+ it("未知 mode → 错误信息 + 可用模式列表 + Usage 含 rule/model", () => {
169
+ const msg = handlePermissionCommand("invalid-mode", makeConfig(), successSave());
170
+ expect(msg).toContain("Unknown mode");
171
+ expect(msg).toContain("invalid-mode");
172
+ expect(msg).toContain("yolo, auto, approve, strict");
173
+ // M6:Usage 提示应列出所有子命令(含 rule/model)
174
+ expect(msg).toContain("rule");
175
+ expect(msg).toContain("model");
176
+ });
177
+
178
+ it("拼错的 mode(yoloo)→ 错误提示", () => {
179
+ const msg = handlePermissionCommand("yoloo", makeConfig(), successSave());
180
+ expect(msg).toContain("Unknown mode");
181
+ expect(msg).toContain("yoloo");
182
+ });
183
+
184
+ it("大小写敏感(YOLO 大写无效)", () => {
185
+ const msg = handlePermissionCommand("YOLO", makeConfig(), successSave());
186
+ expect(msg).toContain("Unknown mode");
187
+ expect(msg).toContain("YOLO");
188
+ });
189
+
190
+ it("未知子命令(foo)当 invalid mode 处理", () => {
191
+ const msg = handlePermissionCommand("foo", makeConfig(), successSave());
192
+ expect(msg).toContain("Unknown mode");
193
+ });
194
+ });
195
+
196
+ // ──────────────────────── 边界 ────────────────────────
197
+
198
+ describe("/permission 边界", () => {
199
+ it("args 含前后空白 → trim 后识别模式", () => {
200
+ const msg = handlePermissionCommand(" strict ", makeConfig({ mode: "yolo" }), successSave());
201
+ expect(msg).toContain("Switched to Strict mode");
202
+ });
203
+
204
+ it("default config 调无参 → 含 'yolo'(默认模式)", () => {
205
+ const msg = handlePermissionCommand(undefined, DEFAULT_CONFIG, successSave());
206
+ expect(msg).toContain("yolo");
207
+ });
208
+ });
209
+
210
+ // ──────────────────────── /permission model(W7 T6) ────────────────────────
211
+
212
+ /** 构造一条 ResolvedModelEntry(测试 helper)。 */
213
+ function makeModelEntry(provider: string, id: string, inputCost: number): ResolvedModelEntry {
214
+ return {
215
+ provider,
216
+ id,
217
+ name: id,
218
+ api: "openai-completions",
219
+ cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
220
+ hasApiKey: true,
221
+ };
222
+ }
223
+
224
+ /** 构造 mock ctx(mode + ui.notify/custom/select)。 */
225
+ function makeModelPickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelPickerContext {
226
+ const base: ModelPickerContext = {
227
+ mode: "rpc",
228
+ ui: {
229
+ notify: vi.fn(),
230
+ select: vi.fn(() => Promise.resolve("Auto")),
231
+ custom: vi.fn(() => Promise.resolve(undefined)),
232
+ },
233
+ };
234
+ return { mode: overrides.mode ?? base.mode, ui: { ...base.ui, ...overrides.ui } };
235
+ }
236
+
237
+ /** 构造 mock deps(listModels + save)。 */
238
+ function makeModelDeps(overrides: Partial<PermissionModelCommandDeps> = {}): PermissionModelCommandDeps {
239
+ return {
240
+ listModels: overrides.listModels ?? (() => new Map([["co", [makeModelEntry("co", "m1", 0.1)]]])),
241
+ save: overrides.save ?? vi.fn(() => ({ success: true })),
242
+ };
243
+ }
244
+
245
+ describe("/permission model(W7)", () => {
246
+ it("无可用 model(空 Map)→ notify 降级提示,不调 save", async () => {
247
+ const ctx = makeModelPickerCtx();
248
+ const save = vi.fn(() => ({ success: true }));
249
+ const deps = makeModelDeps({ listModels: () => new Map(), save });
250
+ await handlePermissionModelCommand(ctx, makeConfig(), deps);
251
+ expect(ctx.ui.notify).toHaveBeenCalledOnce();
252
+ const [msg, level] = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls[0]!;
253
+ expect(msg).toContain("No available models");
254
+ expect(level).toBe("warning");
255
+ expect(save).not.toHaveBeenCalled();
256
+ });
257
+
258
+ it("选 'Auto' → 写回 config.classifier.model='auto' + notify 成功", async () => {
259
+ const ctx = makeModelPickerCtx({
260
+ ui: {
261
+ notify: vi.fn(),
262
+ select: vi.fn(() => Promise.resolve("Auto")),
263
+ custom: vi.fn(),
264
+ },
265
+ });
266
+ const save = vi.fn(() => ({ success: true }));
267
+ const deps = makeModelDeps({ save });
268
+ const config = makeConfig({ classifier: { enabled: true, model: "co/old", timeout: 90, autoApproveLowRisk: true, autoDenyHighRisk: true } });
269
+ await handlePermissionModelCommand(ctx, config, deps);
270
+ expect(save).toHaveBeenCalledOnce();
271
+ const savedConfig = save.mock.calls[0]![0] as PermissionConfig;
272
+ expect(savedConfig.classifier.model).toBe("auto");
273
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("set to: auto"), "info");
274
+ });
275
+
276
+ it("选 provider/model → 写回 config.classifier.model='provider/model-id' + notify", async () => {
277
+ const selectMock = vi.fn()
278
+ .mockResolvedValueOnce("co") // provider
279
+ .mockResolvedValueOnce("m1"); // model
280
+ const ctx = makeModelPickerCtx({
281
+ ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
282
+ });
283
+ const save = vi.fn(() => ({ success: true }));
284
+ const deps = makeModelDeps({ save });
285
+ await handlePermissionModelCommand(ctx, makeConfig(), deps);
286
+ expect(save).toHaveBeenCalledOnce();
287
+ const savedConfig = save.mock.calls[0]![0] as PermissionConfig;
288
+ expect(savedConfig.classifier.model).toBe("co/m1");
289
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("set to: co/m1"), "info");
290
+ });
291
+
292
+ it("cancel(undefined)→ notify 取消,不调 save", async () => {
293
+ const ctx = makeModelPickerCtx({
294
+ ui: {
295
+ notify: vi.fn(),
296
+ select: vi.fn(() => Promise.resolve(undefined)), // cancel
297
+ custom: vi.fn(),
298
+ },
299
+ });
300
+ const save = vi.fn(() => ({ success: true }));
301
+ const deps = makeModelDeps({ save });
302
+ await handlePermissionModelCommand(ctx, makeConfig(), deps);
303
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("cancelled"), "info");
304
+ expect(save).not.toHaveBeenCalled();
305
+ });
306
+
307
+ it("save 失败 → notify error(含 error 信息),不抛错", async () => {
308
+ const ctx = makeModelPickerCtx({
309
+ ui: {
310
+ notify: vi.fn(),
311
+ select: vi.fn(() => Promise.resolve("Auto")),
312
+ custom: vi.fn(),
313
+ },
314
+ });
315
+ const save = vi.fn(() => ({ success: false, error: "disk full" }));
316
+ const deps = makeModelDeps({ save });
317
+ await handlePermissionModelCommand(ctx, makeConfig(), deps);
318
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("disk full"), "error");
319
+ });
320
+
321
+ it("handler 返回 Promise(async 签名)", () => {
322
+ const ctx = makeModelPickerCtx();
323
+ const deps = makeModelDeps();
324
+ const result = handlePermissionModelCommand(ctx, makeConfig(), deps);
325
+ // Promise.is 兼容检查
326
+ expect(result).toBeInstanceOf(Promise);
327
+ // 避免 unhandled rejection
328
+ result.catch(() => undefined);
329
+ });
330
+
331
+ it("写回保留其余字段(仅改 classifier.model)", async () => {
332
+ const ctx = makeModelPickerCtx({
333
+ ui: {
334
+ notify: vi.fn(),
335
+ select: vi.fn(() => Promise.resolve("Auto")),
336
+ custom: vi.fn(),
337
+ },
338
+ });
339
+ const save = vi.fn(() => ({ success: true }));
340
+ const deps = makeModelDeps({ save });
341
+ const config = makeConfig({
342
+ mode: "strict",
343
+ enabled: false,
344
+ classifier: { enabled: false, model: "co/old", timeout: 30, autoApproveLowRisk: false, autoDenyHighRisk: false },
345
+ userRules: [{ id: "u1", tool: "bash", pattern: "rm *", action: "deny", source: "user" }],
346
+ });
347
+ await handlePermissionModelCommand(ctx, config, deps);
348
+ const saved = save.mock.calls[0]![0] as PermissionConfig;
349
+ expect(saved.mode).toBe("strict"); // 保留
350
+ expect(saved.enabled).toBe(false); // 保留
351
+ expect(saved.classifier.enabled).toBe(false); // 保留
352
+ expect(saved.classifier.timeout).toBe(30); // 保留
353
+ expect(saved.classifier.model).toBe("auto"); // 改
354
+ expect(saved.userRules).toHaveLength(1); // 保留
355
+ });
356
+ });
357
+
358
+ // ──────────────────────── /permission rule(W8 T9) ────────────────────────
359
+
360
+ /** 构造 RuleEditorContext mock。 */
361
+ function makeRuleEditorCtx(overrides: Partial<RuleEditorContext> = {}): RuleEditorContext {
362
+ return {
363
+ mode: overrides.mode ?? "rpc",
364
+ ui: {
365
+ notify: overrides.ui?.notify ?? vi.fn(),
366
+ select: overrides.ui?.select ?? vi.fn(() => Promise.resolve(undefined)),
367
+ custom: overrides.ui?.custom ?? vi.fn(() => Promise.resolve(undefined)),
368
+ },
369
+ };
370
+ }
371
+
372
+ /** 构造 PermissionRuleCommandDeps mock。 */
373
+ function makeRuleDeps(overrides: Partial<PermissionRuleCommandDeps> = {}): PermissionRuleCommandDeps {
374
+ return {
375
+ save: overrides.save ?? vi.fn(() => ({ success: true })),
376
+ editRulesViaOverlay: overrides.editRulesViaOverlay ?? vi.fn(() => Promise.resolve(undefined)),
377
+ };
378
+ }
379
+
380
+ function makeRule(overrides: Partial<Rule> = {}): Rule {
381
+ return {
382
+ id: "user-1",
383
+ tool: "bash",
384
+ pattern: "npm *",
385
+ action: "allow",
386
+ source: "user",
387
+ ...overrides,
388
+ };
389
+ }
390
+
391
+ function makeCounter(): () => string {
392
+ let n = 1;
393
+ return (): string => `user-${n++}`;
394
+ }
395
+
396
+ describe("/permission rule(W8)", () => {
397
+ it("editRulesViaOverlay 返回 undefined → notify no changes,不调 save", async () => {
398
+ const notify = vi.fn();
399
+ const save = vi.fn(() => ({ success: true }));
400
+ const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
401
+ const deps = makeRuleDeps({
402
+ save,
403
+ editRulesViaOverlay: vi.fn(() => Promise.resolve(undefined)),
404
+ });
405
+ await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), deps);
406
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("No changes"), "info");
407
+ expect(save).not.toHaveBeenCalled();
408
+ });
409
+
410
+ it("editRulesViaOverlay 返回空 ops → notify no changes", async () => {
411
+ const notify = vi.fn();
412
+ const save = vi.fn(() => ({ success: true }));
413
+ const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
414
+ const deps = makeRuleDeps({
415
+ save,
416
+ editRulesViaOverlay: vi.fn(() => Promise.resolve([])),
417
+ });
418
+ await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), deps);
419
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("No changes"), "info");
420
+ expect(save).not.toHaveBeenCalled();
421
+ });
422
+
423
+ it("ops 非空 → applyOps + save + notify N changes", async () => {
424
+ const notify = vi.fn();
425
+ const save = vi.fn(() => ({ success: true }));
426
+ const ops: RuleOp[] = [{ kind: "add", rule: makeRule({ id: "user-1", pattern: "git *" }) }];
427
+ const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
428
+ const deps = makeRuleDeps({
429
+ save,
430
+ editRulesViaOverlay: vi.fn(() => Promise.resolve(ops)),
431
+ });
432
+ const config = makeConfig({ userRules: [] });
433
+ await handlePermissionRuleCommand(ctx, config, makeCounter(), deps);
434
+ expect(save).toHaveBeenCalledOnce();
435
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 change(s)"), "info");
436
+ // save 收到的 config.userRules 含新规则
437
+ const savedConfig = save.mock.calls[0]![0] as PermissionConfig;
438
+ expect(savedConfig.userRules).toHaveLength(1);
439
+ expect(savedConfig.userRules[0]!.pattern).toBe("git *");
440
+ });
441
+
442
+ it("save 失败 → notify error", async () => {
443
+ const notify = vi.fn();
444
+ const save = vi.fn(() => ({ success: false, error: "disk full" }));
445
+ const ops: RuleOp[] = [{ kind: "add", rule: makeRule({ id: "user-1" }) }];
446
+ const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
447
+ const deps = makeRuleDeps({
448
+ save,
449
+ editRulesViaOverlay: vi.fn(() => Promise.resolve(ops)),
450
+ });
451
+ await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), deps);
452
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("disk full"), "error");
453
+ });
454
+
455
+ it("保存保留 mode/enabled/classifier(仅改 userRules)", async () => {
456
+ const save = vi.fn(() => ({ success: true }));
457
+ const ops: RuleOp[] = [{ kind: "add", rule: makeRule({ id: "user-1" }) }];
458
+ const ctx = makeRuleEditorCtx({ ui: { notify: vi.fn(), select: vi.fn(), custom: vi.fn() } });
459
+ const deps = makeRuleDeps({
460
+ save,
461
+ editRulesViaOverlay: vi.fn(() => Promise.resolve(ops)),
462
+ });
463
+ const config = makeConfig({
464
+ mode: "strict",
465
+ enabled: false,
466
+ classifier: { enabled: false, model: "custom", timeout: 10, autoApproveLowRisk: false, autoDenyHighRisk: false },
467
+ });
468
+ await handlePermissionRuleCommand(ctx, config, makeCounter(), deps);
469
+ const saved = save.mock.calls[0]![0] as PermissionConfig;
470
+ expect(saved.mode).toBe("strict");
471
+ expect(saved.enabled).toBe(false);
472
+ expect(saved.classifier.model).toBe("custom");
473
+ });
474
+
475
+ it("editRulesViaOverlay 收到正确的 initialRules 和 sessionIdCounter", async () => {
476
+ const editMock = vi.fn(() => Promise.resolve(undefined));
477
+ const ctx = makeRuleEditorCtx({ ui: { notify: vi.fn(), select: vi.fn(), custom: vi.fn() } });
478
+ const existingRules = [makeRule({ id: "user-5" })];
479
+ const counter = makeCounter();
480
+ const deps = makeRuleDeps({ editRulesViaOverlay: editMock });
481
+ await handlePermissionRuleCommand(ctx, makeConfig({ userRules: existingRules }), counter, deps);
482
+ expect(editMock).toHaveBeenCalledOnce();
483
+ const [_callCtx, callRules, callCounter] = editMock.mock.calls[0]!;
484
+ expect(callRules).toEqual(existingRules);
485
+ expect(typeof callCounter).toBe("function");
486
+ });
487
+ });