@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,102 @@
1
+ /**
2
+ * production.test.ts — createProductionClassifier + createPipelineDeps 装配测试。
3
+ *
4
+ * getApiProvider 来自 pi-ai,测试时用真实 SDK(vitest alias 到 @earendil-works/pi-ai)。
5
+ * 装配后验证:classifyRisk 可调(走 fallback 路径,无真实 provider 时返回 ask)、
6
+ * deps 字段齐全、analyzeBashStructure/matchRulesForArgv/getDefaultRules 是真实实现。
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import type { ApprovalContext } from "../approval.js";
11
+ import type { CheckPermissionDeps } from "../pipeline.js";
12
+ import { _resetClassifierSingletonForTest, createPipelineDeps, createProductionClassifier } from "../production.js";
13
+
14
+ function makeApprovalCtx(): ApprovalContext {
15
+ return {
16
+ mode: "headless",
17
+ ui: {
18
+ notify() {},
19
+ select() {
20
+ return Promise.resolve(undefined);
21
+ },
22
+ custom() {
23
+ return Promise.resolve(undefined);
24
+ },
25
+ },
26
+ };
27
+ }
28
+
29
+ describe("createProductionClassifier", () => {
30
+ it("返回带 classifyRisk 的对象", () => {
31
+ const classifier = createProductionClassifier();
32
+ expect(typeof classifier.classifyRisk).toBe("function");
33
+ });
34
+
35
+ it("classifyRisk 在无模型时 fail-closed 返回 ask(不 throw)", async () => {
36
+ const classifier = createProductionClassifier();
37
+ // 用不存在的 provider/model 规格强制 resolveClassifierModel 返回 null → fallback ask。
38
+ // (真实环境磁盘存在带 apiKey 的 models.json,model:"auto" 会解析到真实模型并调用 LLM,
39
+ // 无法稳定走 fail-closed 路径,故用 bogus spec 确保无可用模型。)
40
+ const result = await classifier.classifyRisk(
41
+ { toolName: "bash", command: "ls", cwd: "/tmp" },
42
+ { enabled: true, model: "nonexistent-provider/no-such-model", timeout: 5, autoApproveLowRisk: false, autoDenyHighRisk: true },
43
+ );
44
+ expect(result.outcome).toBe("ask");
45
+ expect(result.risk_level).toBe("medium");
46
+ });
47
+ });
48
+
49
+ describe("createPipelineDeps", () => {
50
+ it("装配完整 CheckPermissionDeps(5 个字段齐全)", () => {
51
+ const deps = createPipelineDeps(makeApprovalCtx());
52
+ expect(deps).toBeInstanceOf(Object);
53
+ expect(typeof deps.analyzeBashStructure).toBe("function");
54
+ expect(typeof deps.matchRulesForArgv).toBe("function");
55
+ expect(typeof deps.getDefaultRules).toBe("function");
56
+ expect(typeof deps.classifier.classifyRisk).toBe("function");
57
+ expect(typeof deps.requestUserApproval).toBe("function");
58
+ });
59
+
60
+ it("getDefaultRules 返回内置危险规则(12 条)", () => {
61
+ const deps = createPipelineDeps(makeApprovalCtx());
62
+ const rules = deps.getDefaultRules();
63
+ expect(rules.length).toBe(12);
64
+ expect(rules.every((r) => r.source === "builtin-danger")).toBe(true);
65
+ });
66
+
67
+ it("analyzeBashStructure 是真实实现(干净命令 → clean=true)", async () => {
68
+ const deps = createPipelineDeps(makeApprovalCtx());
69
+ const analysis = await deps.analyzeBashStructure("ls -la");
70
+ expect(analysis.clean).toBe(true);
71
+ expect(analysis.commands).toEqual([["ls", "-la"]]);
72
+ });
73
+
74
+ it("requestUserApproval 走 headless 分支(M1:signal abort → fail-closed deny)", async () => {
75
+ const deps = createPipelineDeps(makeApprovalCtx());
76
+ // M1:headless 无 signal 时永挂,故用已 aborted 的 signal 触发 fail-closed deny。
77
+ const controller = new AbortController();
78
+ controller.abort();
79
+ const decision = await deps.requestUserApproval(
80
+ { toolName: "bash", command: "rm", reason: "test" },
81
+ { toolName: "bash", command: "rm", cwd: "/tmp" },
82
+ controller.signal,
83
+ );
84
+ expect(decision.approved).toBe(false);
85
+ expect(decision.reason).toContain("headless");
86
+ });
87
+
88
+ it("deps 满足 CheckPermissionDeps 类型(结构兼容)", () => {
89
+ const deps: CheckPermissionDeps = createPipelineDeps(makeApprovalCtx());
90
+ // 仅验证类型兼容(运行时已在上面测试)
91
+ expect(deps).toBeDefined();
92
+ });
93
+
94
+ it("m1:多次 createPipelineDeps 复用同一 classifier 单例", () => {
95
+ _resetClassifierSingletonForTest();
96
+ const deps1 = createPipelineDeps(makeApprovalCtx());
97
+ const deps2 = createPipelineDeps(makeApprovalCtx());
98
+ // classifier 是单例,两次装配应引用同一对象(classifyRisk 引用相同)
99
+ expect(deps1.classifier).toBe(deps2.classifier);
100
+ expect(deps1.classifier.classifyRisk).toBe(deps2.classifier.classifyRisk);
101
+ });
102
+ });
@@ -0,0 +1,475 @@
1
+ /**
2
+ * rule-editor-component.test.ts — W8 T7: RuleEditorComponent 单元测试。
3
+ *
4
+ * 覆盖:
5
+ * - 构造 + 初始 list stage
6
+ * - list stage 选择规则 → fill(edit)
7
+ * - list stage [+ Add] → add stage
8
+ * - add stage 选模板 → fill stage
9
+ * - [Done] → settle ops
10
+ * - _resolved 守卫
11
+ * - currentListRules 实时反映 ops(G2)
12
+ * - WR1 handleInput 锁定(Container override 委托)
13
+ * - Tab 焦点路由(WR8)
14
+ */
15
+ import { describe, expect, it, vi } from "vitest";
16
+
17
+ import { RuleEditorComponent } from "../rule-editor-component.js";
18
+ import type { RuleOp } from "../rule-templates.js";
19
+ import type { Rule } from "../types.js";
20
+
21
+ // ──────────────────────── helpers ────────────────────────
22
+
23
+ function makeRule(overrides: Partial<Rule> = {}): Rule {
24
+ return {
25
+ id: "user-1",
26
+ tool: "bash",
27
+ pattern: "npm *",
28
+ action: "allow",
29
+ source: "user",
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ /** 构造一个 RuleEditorComponent 并收集 done 结果。 */
35
+ function createComp(
36
+ initialRules: Rule[] = [],
37
+ doneFn?: (ops: RuleOp[]) => void,
38
+ ): { comp: RuleEditorComponent; done: ReturnType<typeof vi.fn>; counter: () => string } {
39
+ let nextId = 1;
40
+ const counter = (): string => `user-${nextId++}`;
41
+ const done = vi.fn(doneFn ?? (() => undefined));
42
+ const comp = new RuleEditorComponent(initialRules, counter, done);
43
+ return { comp, done, counter };
44
+ }
45
+
46
+ // ──────────────────────── 构造 + 初始 list stage ────────────────────────
47
+
48
+ describe("REC1: 构造 + 初始 list stage", () => {
49
+ it("构造不抛错,render 非空", () => {
50
+ const { comp } = createComp();
51
+ const lines = comp.render(80);
52
+ expect(lines.length).toBeGreaterThan(0);
53
+ });
54
+
55
+ it("初始 render 含 'Permission Rules'", () => {
56
+ const { comp } = createComp([makeRule()]);
57
+ expect(comp.render(80).join("\n")).toContain("Permission Rules");
58
+ });
59
+
60
+ it("list stage 含已有规则 + [+ Add rule] + [Done]", () => {
61
+ const { comp } = createComp([makeRule({ id: "user-1", pattern: "npm *" })]);
62
+ const text = comp.render(80).join("\n");
63
+ expect(text).toContain("npm *");
64
+ expect(text).toContain("[+ Add rule]");
65
+ expect(text).toContain("[Done]");
66
+ });
67
+ });
68
+
69
+ // ──────────────────────── list stage Enter 行为 ────────────────────────
70
+
71
+ describe("REC2: list stage Enter 行为", () => {
72
+ it("Enter 选 [+ Add rule] → add stage(render 含 'Select Template')", () => {
73
+ const { comp } = createComp();
74
+ // 预选在第一条([+ Add rule]),因为无规则时它是第一项
75
+ // 但有 [Done] 在最后,所以 [+ Add rule] 是 index 0(无规则时)
76
+ comp.handleInput("\r"); // Enter
77
+ const text = comp.render(80).join("\n");
78
+ expect(text).toContain("Select Template");
79
+ });
80
+
81
+ it("Enter 选 [Done] → settle 空 ops(无规则时 [Done] 是第二项)", () => {
82
+ const { comp, done } = createComp();
83
+ comp.handleInput("\x1b[B"); // Down → [Done]
84
+ comp.handleInput("\r"); // Enter
85
+ expect(done).toHaveBeenCalledOnce();
86
+ const ops = done.mock.calls[0]![0] as RuleOp[];
87
+ expect(ops).toEqual([]);
88
+ });
89
+ });
90
+
91
+ // ──────────────────────── add stage 选模板 ────────────────────────
92
+
93
+ describe("REC3: add stage 选模板", () => {
94
+ it("选 allow-family 模板 → fill stage(render 含 'Select command')", () => {
95
+ const { comp } = createComp();
96
+ // 进 add stage
97
+ comp.handleInput("\r"); // Enter → add stage([+ Add rule] 预选)
98
+ // add stage 第一条是 allow-family,Enter 选中
99
+ comp.handleInput("\r");
100
+ const text = comp.render(80).join("\n");
101
+ expect(text).toContain("Select command");
102
+ });
103
+
104
+ it("add stage Esc → 回 list stage", () => {
105
+ const { comp } = createComp();
106
+ comp.handleInput("\r"); // → add stage
107
+ comp.handleInput("\x1b"); // Esc
108
+ const text = comp.render(80).join("\n");
109
+ expect(text).toContain("Permission Rules");
110
+ expect(text).not.toContain("Select Template");
111
+ });
112
+ });
113
+
114
+ // ──────────────────────── fill stage:命令选择 + commitFill ────────────────────────
115
+
116
+ describe("REC4: fill stage 命令选择 + commitFill", () => {
117
+ it("选 npm → commitFill → 回 list stage,新规则出现", () => {
118
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
119
+ const { comp, done } = createComp();
120
+ // → add stage → 选 allow-family → fill stage(command-select 带搜索)
121
+ comp.handleInput("\r"); // [+ Add rule]
122
+ comp.handleInput("\r"); // allow-family
123
+ // fill stage:搜索框默认获焦,Enter(空 query)→ 选中第一个匹配 npm → 进入 scope 列表
124
+ comp.handleInput("\r");
125
+ // scope 列表:第一条是 `${cmd} * (all subcommands)`(__all__),Enter → commitFill
126
+ comp.handleInput("\r");
127
+ // commitFill → switchToListStage,list 现在应有新规则
128
+ const text = comp.render(80).join("\n");
129
+ expect(text).toContain("npm *");
130
+ expect(comp.ops).toHaveLength(1);
131
+ expect(comp.ops[0]!.kind).toBe("add");
132
+ expect(comp.ops[0]!.rule.pattern).toBe("npm *");
133
+ expect(comp.ops[0]!.rule.action).toBe("allow");
134
+ });
135
+ });
136
+
137
+ // ──────────────────────── _resolved 守卫 ────────────────────────
138
+
139
+ describe("REC5: _resolved 守卫", () => {
140
+ it("settle 后再 handleInput no-op(done 只调一次)", () => {
141
+ const { comp, done } = createComp();
142
+ // 选 [Done]
143
+ comp.handleInput("\r"); // [+ Add rule](但无规则时第一条是 [+ Add rule])
144
+ // 实际无规则时 items = ["[+ Add rule]", "[Done]"]
145
+ // Enter 选了 [+ Add rule],进 add stage
146
+ // 回到 list 用 cancel
147
+ comp.handleInput("\x1b"); // Esc → back to list
148
+ // 现在 list 有 [+ Add rule] 和 [Done]
149
+ comp.handleInput("\x1b[B"); // Down → [Done]
150
+ comp.handleInput("\r"); // Enter → settle
151
+ expect(done).toHaveBeenCalledOnce();
152
+ // 再次输入 no-op
153
+ comp.handleInput("\r");
154
+ expect(done).toHaveBeenCalledOnce();
155
+ });
156
+ });
157
+
158
+ // ──────────────────────── currentListRules(G2) ────────────────────────
159
+
160
+ describe("REC6: currentListRules 实时反映 ops(G2)", () => {
161
+ it("初始 currentListRules 等于 initialRules", () => {
162
+ const rules = [makeRule({ id: "user-1" })];
163
+ const { comp } = createComp(rules);
164
+ expect(comp.currentListRules()).toEqual(rules);
165
+ });
166
+
167
+ it("ops.push add 后 currentListRules 包含新规则", () => {
168
+ const { comp } = createComp([makeRule({ id: "user-1" })]);
169
+ comp.ops.push({ kind: "add", rule: makeRule({ id: "user-2", pattern: "git *" }) });
170
+ const rules = comp.currentListRules();
171
+ expect(rules).toHaveLength(2);
172
+ expect(rules[1]!.pattern).toBe("git *");
173
+ });
174
+ });
175
+
176
+ // ──────────────────────── WR1 handleInput 锁定 ────────────────────────
177
+
178
+ describe("REC7: WR1 handleInput 委托锁定(critical)", () => {
179
+ it("handleInput('\\r') 直接触发 SelectList.onSelect(list stage [Done])", () => {
180
+ const { comp, done } = createComp();
181
+ // list stage:无规则 → items = [[+ Add rule], [Done]]
182
+ // Down → [Done]
183
+ comp.handleInput("\x1b[B");
184
+ comp.handleInput("\r");
185
+ expect(done).toHaveBeenCalledOnce();
186
+ });
187
+
188
+ it("handleInput(down + enter) 链式触发导航 + onSelect", () => {
189
+ const { comp, done } = createComp([makeRule({ id: "user-1", pattern: "npm *" })]);
190
+ // list items: [npm * (rule), [+ Add rule], [Done]]
191
+ // 预选 index 0(npm *),Enter → fill(edit)
192
+ comp.handleInput("\r");
193
+ // fill(edit) 模式,render 应含 Edit Rule
194
+ const text = comp.render(80).join("\n");
195
+ expect(text).toContain("Edit Rule");
196
+ // done 不应被调(还在 fill stage)
197
+ expect(done).not.toHaveBeenCalled();
198
+ });
199
+ });
200
+
201
+ // ──────────────────────── WR8 Tab 焦点路由 ────────────────────────
202
+
203
+ describe("REC8: WR8 Tab 焦点路由(Custom form)", () => {
204
+ it("custom form Tab 循环焦点(5 项)", () => {
205
+ const { comp } = createComp();
206
+ // → add stage
207
+ comp.handleInput("\r"); // [+ Add rule]
208
+ // 选 custom(最后一条模板,index 4)
209
+ for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B"); // Down x4
210
+ comp.handleInput("\r"); // Enter → custom fill
211
+ const text = comp.render(80).join("\n");
212
+ expect(text).toContain("Custom Rule");
213
+ // Tab 5 次应回到原焦点(模 5)
214
+ for (let i = 0; i < 5; i++) comp.handleInput("\t");
215
+ // 不应抛错
216
+ expect(comp.render(80).length).toBeGreaterThan(0);
217
+ });
218
+ });
219
+
220
+ // ──────────────────────── cancel ────────────────────────
221
+
222
+ describe("REC9: cancel", () => {
223
+ it("cancel() 调 done(ops)(可能为空)", () => {
224
+ const { comp, done } = createComp();
225
+ comp.cancel();
226
+ expect(done).toHaveBeenCalledOnce();
227
+ });
228
+
229
+ it("cancel 后 _resolved 守卫生效", () => {
230
+ const { comp, done } = createComp();
231
+ comp.cancel();
232
+ comp.cancel();
233
+ expect(done).toHaveBeenCalledOnce();
234
+ });
235
+ });
236
+
237
+ // ──────────────────────── Wave 2: edit 提交保留改动(M1 + M2) ────────────────────────
238
+
239
+ describe("REC10: Wave 2 — edit 提交保留改动(M1)", () => {
240
+ it("edit 规则改 action 后 ops 含 edit 且字段正确", () => {
241
+ const initialRule = makeRule({ id: "user-1", pattern: "npm *", action: "allow" });
242
+ const { comp } = createComp([initialRule]);
243
+ // list stage 第一条是规则,Enter 进 edit(custom form,edit 模式)
244
+ comp.handleInput("\r");
245
+ // 焦点在 pattern(index 0),Tab 到 action(index 1)
246
+ comp.handleInput("\t"); // → action
247
+ comp.handleInput("\x1b[B"); // Down → deny(移动指针)
248
+ comp.handleInput("\r"); // Enter → actionList.onSelect → fillSelections.action = "deny"
249
+ // Tab 经 tool / description 到 submit
250
+ comp.handleInput("\t"); // → tool (index 2)
251
+ comp.handleInput("\t"); // → description (index 3)
252
+ comp.handleInput("\t"); // → submit (index 4)
253
+ comp.handleInput("\r"); // submit → commitFill
254
+ // 回到 list stage,ops 应含 1 个 edit
255
+ expect(comp.ops).toHaveLength(1);
256
+ expect(comp.ops[0]!.kind).toBe("edit");
257
+ const editOp = comp.ops[0]!;
258
+ if (editOp.kind === "edit") {
259
+ expect(editOp.id).toBe("user-1");
260
+ expect(editOp.rule.id).toBe("user-1");
261
+ expect(editOp.rule.pattern).toBe("npm *"); // 未改 pattern
262
+ expect(editOp.rule.action).toBe("deny"); // 改成 deny
263
+ expect(editOp.rule.tool).toBe("bash");
264
+ }
265
+ });
266
+
267
+ it("edit 提交后 pattern 通过 _syncCustomFormValues 保留(即使没按 Enter 确认 Input)", () => {
268
+ // 验证 M2:Input.onSubmit 仅在 Enter 时触发;Tab 切走不触发。
269
+ // commitFill 入口的 _syncCustomFormValues 兜底读 getValue,确保预填值不丢。
270
+ const initialRule = makeRule({ id: "user-1", pattern: "npm *", action: "allow" });
271
+ const { comp } = createComp([initialRule]);
272
+ comp.handleInput("\r"); // 进 edit
273
+ // 焦点在 pattern,直接 Tab 到 submit(不按 Enter 确认 pattern)
274
+ comp.handleInput("\t"); // → action
275
+ comp.handleInput("\t"); // → tool
276
+ comp.handleInput("\t"); // → description
277
+ comp.handleInput("\t"); // → submit
278
+ comp.handleInput("\r"); // submit → commitFill(入口调 _syncCustomFormValues)
279
+ expect(comp.ops).toHaveLength(1);
280
+ const editOp = comp.ops[0]!;
281
+ if (editOp.kind === "edit") {
282
+ expect(editOp.rule.pattern).toBe("npm *"); // _syncCustomFormValues 读到预填值
283
+ expect(editOp.rule.action).toBe("allow"); // 原 action 保留
284
+ }
285
+ });
286
+
287
+ it("edit 模式 commitFill 绕过 fillTemplate.build(M1:不再被 fillTemplate===null 吞掉)", () => {
288
+ // 直接验证:edit 进入 custom form 后 fillTemplate 始终为 null,
289
+ // 旧逻辑会 switchToListStage() 提前返回,ops 为空。
290
+ // 新逻辑:edit 分支前置,直接构造 Rule。
291
+ const initialRule = makeRule({ id: "user-1", pattern: "git *", action: "ask" });
292
+ const { comp } = createComp([initialRule]);
293
+ comp.handleInput("\r"); // 进 edit
294
+ // 焦点一路 Tab 到 submit,不改任何字段
295
+ for (let i = 0; i < 4; i++) comp.handleInput("\t"); // pattern→action→tool→desc→submit
296
+ comp.handleInput("\r"); // submit
297
+ expect(comp.ops).toHaveLength(1);
298
+ const editOp = comp.ops[0]!;
299
+ if (editOp.kind === "edit") {
300
+ expect(editOp.rule.pattern).toBe("git *");
301
+ expect(editOp.rule.action).toBe("ask");
302
+ }
303
+ });
304
+ });
305
+
306
+ // ──────────────────────── Wave 2: custom form 焦点指示(M4) ────────────────────────
307
+
308
+ describe("REC11: Wave 2 — custom form 焦点指示(M4)", () => {
309
+ it("初始焦点在 Pattern,render 含 ▶ Pattern 标记且不含 ▶ Action", () => {
310
+ const { comp } = createComp();
311
+ // 进 custom form:add → 选 custom 模板(index 4)
312
+ comp.handleInput("\r"); // [+ Add rule]
313
+ for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B"); // Down x4 到 custom
314
+ comp.handleInput("\r"); // Enter custom
315
+ const text = comp.render(80).join("\n");
316
+ expect(text).toContain("▶ Pattern");
317
+ expect(text).not.toContain("▶ Action");
318
+ });
319
+
320
+ it("Tab 后焦点到 Action,render 含 ▶ Action 标记且 ▶ Pattern 消失", () => {
321
+ const { comp } = createComp();
322
+ comp.handleInput("\r");
323
+ for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B");
324
+ comp.handleInput("\r");
325
+ comp.handleInput("\t"); // Tab → Action
326
+ const text = comp.render(80).join("\n");
327
+ expect(text).toContain("▶ Action");
328
+ expect(text).not.toContain("▶ Pattern");
329
+ });
330
+
331
+ it("连续 Tab 循环焦点:Pattern → Action → Tool → Description → 回 Pattern 区域无重复 ▶", () => {
332
+ const { comp } = createComp();
333
+ comp.handleInput("\r");
334
+ for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B");
335
+ comp.handleInput("\r");
336
+ // 焦点 0=Pattern → 1=Action → 2=Tool → 3=Description
337
+ const labels = ["▶ Pattern", "▶ Action", "▶ Tool", "▶ Description (optional)"];
338
+ for (const expected of labels) {
339
+ const text = comp.render(80).join("\n");
340
+ expect(text).toContain(expected);
341
+ comp.handleInput("\t");
342
+ }
343
+ });
344
+ });
345
+
346
+ // ──────────────────────── Wave 3: 搜索过滤一致性(M5) ────────────────────────
347
+
348
+ describe("REC12: Wave 3 — 搜索过滤一致性(M5)", () => {
349
+ it("输入前缀匹配的字符,列表过滤显示对应命令", () => {
350
+ const { comp } = createComp();
351
+ // 进 command-select:add → allow-family
352
+ comp.handleInput("\r"); // [+ Add rule]
353
+ comp.handleInput("\r"); // allow-family(第一条模板,预选)
354
+ // 现在在 command-select,焦点默认在搜索框
355
+ // 输入 'gi'(git value 的前缀,startsWith 匹配)
356
+ comp.handleInput("g");
357
+ comp.handleInput("i");
358
+ const text = comp.render(80).join("\n");
359
+ // git 应在过滤后列表中
360
+ expect(text).toMatch(/git/i);
361
+ // 非前缀匹配的命令不应出现(如 npm,'gi' 不是其前缀)
362
+ expect(text).not.toMatch(/npm/i);
363
+ });
364
+
365
+ it("输入非前缀字符,实时列表不显示不匹配项(startsWith 一致性)", () => {
366
+ const { comp } = createComp();
367
+ comp.handleInput("\r"); // [+ Add rule]
368
+ comp.handleInput("\r"); // allow-family
369
+ // 输入 'install'(不是任何命令 value/label 的前缀;npm install 的 value 是 'npm')
370
+ for (const ch of "install") comp.handleInput(ch);
371
+ const text = comp.render(80).join("\n");
372
+ // npm 不应出现(startsWith 不匹配)
373
+ expect(text).not.toMatch(/npm/i);
374
+ // 实时列表(SelectList.setFilter)无匹配 → 显示 noMatch 文案
375
+ // 注:__other__ 仅在 Enter 选择路径(_filterCommands)保留,不在实时显示保留
376
+ // (SelectList.setFilter 是 pi-tui 内置 value.startsWith,不感知 __other__)
377
+ expect(text).toContain("No matching commands");
378
+ });
379
+
380
+ it("__other__ 在 Enter 选择路径始终保留(_filterCommands 语义)", () => {
381
+ const { comp } = createComp();
382
+ comp.handleInput("\r");
383
+ comp.handleInput("\r");
384
+ // 输入任意字符(无命令 value 以 zzz 开头)
385
+ for (const ch of "zzz") comp.handleInput(ch);
386
+ // 实时显示已被 setFilter 清空(No matching commands),但 _filterCommands
387
+ // 保留 __other__ → Enter 触发 onSubmit → filtered 含 __other__ → 进入手动输入
388
+ comp.handleInput("\r");
389
+ const text = comp.render(80).join("\n");
390
+ expect(text).toContain("Enter command name"); // startCommandInput 界面
391
+ });
392
+ });
393
+
394
+ // ──────────────────────── C1/C2/C3: _resetStageRefs 死锁修复回归 ────────────────────────
395
+
396
+ describe("REC13: C3 — _resetStageRefs 多次 stage 转换不残留旧组件引用(死锁修复回归)", () => {
397
+ // C3 根因:switchTo*/startFill* 方法手动清理子组件引用时遗漏字段,
398
+ // 导致 handleInput 把输入路由到已清空的旧组件(输入死锁)。
399
+ // _resetStageRefs 集中清理 _searchInput/_commandList/_searchFocus/
400
+ // currentInput/currentList/customChildren,杜绝遗漏。
401
+ // 因这些字段是 private,通过行为验证:Esc 回退后再进 stage,
402
+ // 输入字符应作用于「新」组件而非残留的旧组件(不 crash、过滤正确)。
403
+ it("command-select → Esc 回退 → 再进 command-select:输入字符过滤新列表(不路由到旧组件)", () => {
404
+ const { comp } = createComp();
405
+ // 1. 进 command-select stage(add → allow-family)
406
+ comp.handleInput("\r"); // [+ Add rule] → add stage
407
+ comp.handleInput("\r"); // allow-family → command-select stage
408
+ expect(comp.render(80).join("\n")).toContain("Select command");
409
+ // 2. 输入字符到 searchInput(验证旧 _searchInput 活跃:能过滤列表)
410
+ comp.handleInput("g");
411
+ comp.handleInput("i");
412
+ let text = comp.render(80).join("\n");
413
+ expect(text).toMatch(/git/i); // 'gi' 前缀过滤出 git
414
+ // 3. Esc 回退:command-select 的 searchInput.onEscape → switchToListStage
415
+ // → _resetStageRefs 清空 _searchInput/_commandList
416
+ comp.handleInput("\x1b");
417
+ text = comp.render(80).join("\n");
418
+ expect(text).toContain("Permission Rules"); // 回到 list stage
419
+ // 4. 再进 command-select stage(list → add → allow-family)
420
+ comp.handleInput("\r"); // [+ Add rule] → add stage
421
+ comp.handleInput("\r"); // allow-family → command-select stage(新建 _searchInput/_commandList)
422
+ expect(comp.render(80).join("\n")).toContain("Select command");
423
+ // 5. 关键:输入字符应作用于「新」searchInput → 过滤新 commandList
424
+ // 若旧引用残留,过滤会错乱或 crash。输入 'gi' 应再次过滤出 git。
425
+ comp.handleInput("g");
426
+ comp.handleInput("i");
427
+ text = comp.render(80).join("\n");
428
+ expect(text).toMatch(/git/i); // 新列表被正确过滤
429
+ // npm 不应出现('gi' 不是 npm 前缀)—— 证明输入路由到新组件而非旧组件
430
+ expect(text).not.toMatch(/npm/i);
431
+ });
432
+
433
+ it("custom form → Esc 回退 → 再进 command-select:Tab/字符不误触发旧 customChildren", () => {
434
+ // 跨 fillKind 转换(custom → list → command-select):
435
+ // customChildren 在 _resetStageRefs 被清空,再进 command-select 时
436
+ // handleInput 不应走 custom 分支(fillKind 已变为 command-select)。
437
+ const { comp } = createComp();
438
+ // 进 custom form:add → custom 模板(index 4)
439
+ comp.handleInput("\r"); // [+ Add rule]
440
+ for (let i = 0; i < 4; i++) comp.handleInput("\x1b[B"); // Down x4 → custom
441
+ comp.handleInput("\r"); // Enter → custom form
442
+ expect(comp.render(80).join("\n")).toContain("Custom Rule");
443
+ // Esc:pattern Input.onEscape → switchToListStage → _resetStageRefs 清 customChildren
444
+ comp.handleInput("\x1b");
445
+ expect(comp.render(80).join("\n")).toContain("Permission Rules");
446
+ // 再进 command-select
447
+ comp.handleInput("\r"); // [+ Add rule]
448
+ comp.handleInput("\r"); // allow-family → command-select
449
+ expect(comp.render(80).join("\n")).toContain("Select command");
450
+ // 输入字符 + Tab:不应 crash,不应误触发残留 customChildren 的 Tab 路由
451
+ comp.handleInput("g");
452
+ comp.handleInput("\t"); // Tab 切换搜索框↔列表(command-select 语义,非 custom Tab 循环)
453
+ // 不抛错 + 仍处于 command-select stage
454
+ const text = comp.render(80).join("\n");
455
+ expect(text).toContain("Select command");
456
+ });
457
+
458
+ it("连续多次 add↔list 转换不残留 currentList(Enter 选模板稳定生效)", () => {
459
+ // 多次 switchToAddStage/switchToListStage 循环:每次 _resetStageRefs 清 currentList,
460
+ // 新 currentList 正确赋值。验证连续进 add stage 选模板仍能正常进入 fill stage。
461
+ const { comp } = createComp();
462
+ for (let round = 0; round < 3; round++) {
463
+ // list → add
464
+ comp.handleInput("\r"); // [+ Add rule] → add stage
465
+ expect(comp.render(80).join("\n")).toContain("Select Template");
466
+ // add → list(Esc)
467
+ comp.handleInput("\x1b");
468
+ expect(comp.render(80).join("\n")).toContain("Permission Rules");
469
+ }
470
+ // 最后一次进 add 并选 allow-family:currentList 是新建的,onSelect 正常触发
471
+ comp.handleInput("\r"); // → add stage
472
+ comp.handleInput("\r"); // allow-family → command-select
473
+ expect(comp.render(80).join("\n")).toContain("Select command");
474
+ });
475
+ });