@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,456 @@
1
+ /**
2
+ * approval.test.ts — requestUserApproval(TUI/RPC/headless)+ ApprovalComponent +
3
+ * renderApprovalView 测试。覆盖 G3 abort 时序 + G4 invalidate。
4
+ */
5
+ import { describe, expect, it, vi } from "vitest";
6
+
7
+ import {
8
+ ApprovalComponent,
9
+ collectRejectReason,
10
+ type ApprovalContext,
11
+ renderApprovalView,
12
+ requestUserApproval,
13
+ } from "../approval.js";
14
+ import type { ApprovalRequest } from "../pipeline.js";
15
+ import type { ClassifierResult, ToolInvocationContext, UserDecision } from "../types.js";
16
+
17
+ // ──────────────────────── mock helpers ────────────────────────
18
+
19
+ function makeApprovalCtx(overrides: Partial<ApprovalContext> = {}): ApprovalContext {
20
+ const base: ApprovalContext = {
21
+ mode: "headless",
22
+ ui: {
23
+ notify: vi.fn(),
24
+ select: vi.fn(() => Promise.resolve(undefined)),
25
+ custom: vi.fn(() => Promise.resolve(undefined)),
26
+ },
27
+ };
28
+ // 浅合并 ui(保留 base.ui 的方法,允许 overrides.ui 增量覆盖/新增如 input)
29
+ return {
30
+ mode: overrides.mode ?? base.mode,
31
+ ui: { ...base.ui, ...overrides.ui },
32
+ };
33
+ }
34
+
35
+ const req: ApprovalRequest = { toolName: "bash", command: "rm -rf /tmp", reason: "no allow rule" };
36
+ const ctx: ToolInvocationContext = { toolName: "bash", command: "rm -rf /tmp", cwd: "/tmp" };
37
+
38
+ // ──────────────────────── headless 分支(M1) ────────────────────────
39
+
40
+ describe("requestUserApproval: headless 模式(M1)", () => {
41
+ it("json 模式 → 立即 fail-closed deny + notify", async () => {
42
+ const approvalCtx = makeApprovalCtx({ mode: "json" });
43
+ const controller = new AbortController();
44
+ const decision = await requestUserApproval(req, ctx, controller.signal, approvalCtx);
45
+ expect(decision.approved).toBe(false);
46
+ expect(decision.reason).toContain("headless");
47
+ expect(decision.reason).toContain("cannot prompt");
48
+ expect(approvalCtx.ui.notify).toHaveBeenCalledOnce();
49
+ });
50
+
51
+ it("print 模式 → 立即 fail-closed deny + notify", async () => {
52
+ const approvalCtx = makeApprovalCtx({ mode: "print" });
53
+ const decision = await requestUserApproval(req, ctx, new AbortController().signal, approvalCtx);
54
+ expect(decision.approved).toBe(false);
55
+ expect(decision.reason).toContain("headless");
56
+ expect(approvalCtx.ui.notify).toHaveBeenCalledOnce();
57
+ });
58
+
59
+ it("headless 即使 signal 已 aborted 也立即 deny(不依赖 signal)", async () => {
60
+ const approvalCtx = makeApprovalCtx({ mode: "json" });
61
+ const controller = new AbortController();
62
+ controller.abort();
63
+ const decision = await requestUserApproval(req, ctx, controller.signal, approvalCtx);
64
+ expect(decision.approved).toBe(false);
65
+ expect(decision.reason).toContain("headless");
66
+ });
67
+
68
+ // M1 语义:headless auto 的 Racing 在 runLayer3WithRacing 内部通过 isHeadless() 分支处理,
69
+ // requestHeadless 只服务 strict/approve 的 askUser 路径(立即 deny,无 AI 可兜底)。
70
+ });
71
+
72
+ // ──────────────────────── rpc 分支 ────────────────────────
73
+
74
+ describe("requestUserApproval: rpc 模式", () => {
75
+ it("用户选 Approve → approved=true", async () => {
76
+ const approvalCtx = makeApprovalCtx({
77
+ mode: "rpc",
78
+ ui: {
79
+ notify: vi.fn(),
80
+ select: vi.fn(() => Promise.resolve("Approve (once)")),
81
+ custom: vi.fn(() => Promise.resolve(undefined)),
82
+ },
83
+ });
84
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
85
+ expect(decision.approved).toBe(true);
86
+ expect(decision.scope).toBe("once");
87
+ });
88
+
89
+ it("用户选 Deny → approved=false", async () => {
90
+ const approvalCtx = makeApprovalCtx({
91
+ mode: "rpc",
92
+ ui: {
93
+ notify: vi.fn(),
94
+ select: vi.fn(() => Promise.resolve("Deny")),
95
+ custom: vi.fn(() => Promise.resolve(undefined)),
96
+ },
97
+ });
98
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
99
+ expect(decision.approved).toBe(false);
100
+ });
101
+
102
+ it("用户关闭对话框(undefined)→ approved=false", async () => {
103
+ const approvalCtx = makeApprovalCtx({
104
+ mode: "rpc",
105
+ ui: {
106
+ notify: vi.fn(),
107
+ select: vi.fn(() => Promise.resolve(undefined)),
108
+ custom: vi.fn(() => Promise.resolve(undefined)),
109
+ },
110
+ });
111
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
112
+ expect(decision.approved).toBe(false);
113
+ expect(decision.reason).toContain("dismissed");
114
+ });
115
+
116
+ it("M2:signal 已 aborted → 短路 deny,不调 ctx.ui.select", async () => {
117
+ const controller = new AbortController();
118
+ controller.abort();
119
+ const selectMock = vi.fn(() => Promise.resolve("Approve (once)"));
120
+ const approvalCtx = makeApprovalCtx({
121
+ mode: "rpc",
122
+ ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
123
+ });
124
+ const decision = await requestUserApproval(req, ctx, controller.signal, approvalCtx);
125
+ expect(decision.approved).toBe(false);
126
+ expect(decision.reason).toContain("aborted before prompt");
127
+ expect(selectMock).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("M2:signal 透传给 ctx.ui.select(options.signal)", async () => {
131
+ const controller = new AbortController();
132
+ const selectMock = vi.fn((_title, _options, opts) => {
133
+ // 验证 opts.signal 是传入的 controller.signal
134
+ expect(opts?.signal).toBe(controller.signal);
135
+ return Promise.resolve("Deny");
136
+ });
137
+ const approvalCtx = makeApprovalCtx({
138
+ mode: "rpc",
139
+ ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
140
+ });
141
+ await requestUserApproval(req, ctx, controller.signal, approvalCtx);
142
+ expect(selectMock).toHaveBeenCalledOnce();
143
+ });
144
+
145
+ it("M2:无 signal 时不透传 options(select 第三参为 undefined)", async () => {
146
+ const selectMock = vi.fn((_title, _options, opts) => {
147
+ expect(opts).toBeUndefined();
148
+ return Promise.resolve("Approve (once)");
149
+ });
150
+ const approvalCtx = makeApprovalCtx({
151
+ mode: "rpc",
152
+ ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
153
+ });
154
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
155
+ expect(decision.approved).toBe(true);
156
+ expect(selectMock).toHaveBeenCalledOnce();
157
+ });
158
+ });
159
+
160
+ // ──────────────────────── tui 分支 + G3 ────────────────────────
161
+
162
+ describe("requestUserApproval: tui 模式", () => {
163
+ it("G3:signal 已 aborted → 短路 deny,不调 ctx.ui.custom", async () => {
164
+ const controller = new AbortController();
165
+ controller.abort();
166
+ const customMock = vi.fn(() => Promise.resolve({ approved: true } as UserDecision));
167
+ const approvalCtx = makeApprovalCtx({
168
+ mode: "tui",
169
+ ui: { notify: vi.fn(), select: vi.fn(), custom: customMock },
170
+ });
171
+ const decision = await requestUserApproval(req, ctx, controller.signal, approvalCtx);
172
+ expect(decision.approved).toBe(false);
173
+ expect(decision.reason).toContain("aborted before prompt");
174
+ expect(customMock).not.toHaveBeenCalled(); // G3:短路,不进 custom
175
+ });
176
+
177
+ it("G3:signal 未 aborted → 调 ctx.ui.custom,组件 done 决定结果", async () => {
178
+ const approvalCtx = makeApprovalCtx({
179
+ mode: "tui",
180
+ ui: {
181
+ notify: vi.fn(),
182
+ select: vi.fn(),
183
+ custom: vi.fn((_factory) => Promise.resolve({ approved: true, reason: "tui-ok", scope: "once" } as UserDecision)),
184
+ },
185
+ });
186
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
187
+ expect(decision.approved).toBe(true);
188
+ expect(approvalCtx.ui.custom).toHaveBeenCalledOnce();
189
+ });
190
+ });
191
+
192
+ // ──────────────────────── ApprovalComponent + G4 ────────────────────────
193
+
194
+ describe("ApprovalComponent(G4 invalidate)", () => {
195
+ it("G4:implements Component,invalidate() 清空渲染缓存", () => {
196
+ const requestRender = vi.fn();
197
+ const tui = { requestRender };
198
+ const done = vi.fn();
199
+ const comp = new ApprovalComponent(req, tui, done);
200
+ // constructor 调 invalidate → 缓存为空(首次 render 必须实际渲染)
201
+ const first = comp.render(80);
202
+ expect(first.length).toBeGreaterThan(0);
203
+
204
+ // invalidate 清缓存(不直接 requestRender;rerender 才 requestRender)
205
+ requestRender.mockClear();
206
+ comp.invalidate();
207
+ // 再次 render → 重新计算(非缓存)
208
+ const second = comp.render(80);
209
+ expect(second).not.toBe(first); // 不同引用,说明缓存被清
210
+ });
211
+
212
+ it("render 返回非空行数组,含工具名和命令", () => {
213
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, vi.fn());
214
+ const lines = comp.render(80);
215
+ expect(lines.length).toBeGreaterThan(0);
216
+ const joined = lines.join("\n");
217
+ expect(joined).toContain("bash");
218
+ expect(joined).toContain("rm -rf /tmp");
219
+ // 工作区改动去掉了 y/n 快捷键,只保留 Enter(approve)/Esc(deny)。
220
+ expect(joined).toContain("[Enter] Approve");
221
+ });
222
+
223
+ it("handleInput Enter → approve(done 调用一次)", () => {
224
+ const done = vi.fn();
225
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
226
+ // Enter 键:matchesKey(data, "enter") 匹配 "\r"(codepoint 13)
227
+ comp.handleInput("\r");
228
+ expect(done).toHaveBeenCalledOnce();
229
+ const result = done.mock.calls[0]![0] as UserDecision;
230
+ expect(result.approved).toBe(true);
231
+ });
232
+
233
+ it("handleInput Esc → deny", () => {
234
+ const done = vi.fn();
235
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
236
+ // Esc 键:matchesKey(data, "escape") 匹配 "\x1b"(codepoint 27)
237
+ comp.handleInput("\x1b");
238
+ expect(done).toHaveBeenCalledOnce();
239
+ const result = done.mock.calls[0]![0] as UserDecision;
240
+ expect(result.approved).toBe(false);
241
+ });
242
+
243
+ it("handleInput 在 resolved 后 no-op(守卫)", () => {
244
+ const done = vi.fn();
245
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
246
+ comp.handleInput("\r"); // 首次 approve(Enter)
247
+ comp.handleInput("\x1b"); // resolved 后 no-op(Esc 不再生效)
248
+ expect(done).toHaveBeenCalledOnce();
249
+ });
250
+
251
+ it("M2: y 键是 no-op(已移除 y 快捷键,不再触发 approve)", () => {
252
+ const done = vi.fn();
253
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
254
+ comp.handleInput("y");
255
+ expect(done).not.toHaveBeenCalled();
256
+ // 组件仍未 resolve(后续 Enter 仍能 approve)
257
+ comp.handleInput("\r");
258
+ expect(done).toHaveBeenCalledOnce();
259
+ expect((done.mock.calls[0]![0] as UserDecision).approved).toBe(true);
260
+ });
261
+
262
+ it("M2: n 键是 no-op(已移除 n 快捷键,不再触发 deny)", () => {
263
+ const done = vi.fn();
264
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
265
+ comp.handleInput("n");
266
+ expect(done).not.toHaveBeenCalled();
267
+ // 组件仍未 resolve(后续 Esc 仍能 deny)
268
+ comp.handleInput("\x1b");
269
+ expect(done).toHaveBeenCalledOnce();
270
+ expect((done.mock.calls[0]![0] as UserDecision).approved).toBe(false);
271
+ });
272
+
273
+ it("M2: 大写 Y / N 也是 no-op(大小写都不触发)", () => {
274
+ const done = vi.fn();
275
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
276
+ comp.handleInput("Y");
277
+ comp.handleInput("N");
278
+ expect(done).not.toHaveBeenCalled();
279
+ });
280
+
281
+ it("cancel()(signal abort)→ deny,resolved 后 no-op", () => {
282
+ const done = vi.fn();
283
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
284
+ comp.cancel();
285
+ expect(done).toHaveBeenCalledOnce();
286
+ expect((done.mock.calls[0]![0] as UserDecision).approved).toBe(false);
287
+ comp.cancel(); // 二次 no-op
288
+ expect(done).toHaveBeenCalledOnce();
289
+ });
290
+
291
+ it("render 缓存:同 width 第二次返回缓存(行数组引用相同)", () => {
292
+ const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, vi.fn());
293
+ const first = comp.render(80);
294
+ const second = comp.render(80);
295
+ expect(second).toBe(first); // 同一引用(缓存命中)
296
+ comp.invalidate();
297
+ const third = comp.render(80);
298
+ expect(third).not.toBe(first); // invalidate 后重新渲染
299
+ });
300
+ });
301
+
302
+ // ──────────────────────── renderApprovalView(preClassification) ────────────────────────
303
+
304
+ describe("renderApprovalView(含 AI 预分类)", () => {
305
+ it("无 preClassification → 不渲染 AI 块", () => {
306
+ const lines = renderApprovalView(req, 80);
307
+ const joined = lines.join("\n");
308
+ expect(joined).not.toContain("AI classification");
309
+ });
310
+
311
+ it("有 preClassification → 渲染 risk/outcome/confidence", () => {
312
+ const pc: ClassifierResult = {
313
+ risk_level: "high",
314
+ outcome: "deny",
315
+ reasoning: "dangerous pipe to shell",
316
+ confidence: 0.95,
317
+ };
318
+ const reqWithPc: ApprovalRequest = { ...req, preClassification: pc };
319
+ const lines = renderApprovalView(reqWithPc, 80);
320
+ const joined = lines.join("\n");
321
+ expect(joined).toContain("AI classification");
322
+ expect(joined).toContain("high");
323
+ expect(joined).toContain("deny");
324
+ expect(joined).toContain("0.95");
325
+ });
326
+ });
327
+
328
+ // ──────────────────────── W6 T9 G3: Reject-with-Reason ────────────────────────
329
+
330
+ describe("W6 T9 G3: Reject-with-Reason(collectRejectReason)", () => {
331
+ it("ctx.ui.input 存在 → 采集真实 reason", async () => {
332
+ const approvalCtx = makeApprovalCtx({
333
+ mode: "rpc",
334
+ ui: {
335
+ notify: vi.fn(),
336
+ select: vi.fn(),
337
+ custom: vi.fn(),
338
+ input: vi.fn(() => Promise.resolve("destroys production data")),
339
+ },
340
+ });
341
+ const reason = await collectRejectReason(req, approvalCtx);
342
+ expect(reason).toBe("denied via rpc: destroys production data");
343
+ expect(approvalCtx.ui.input).toHaveBeenCalledOnce();
344
+ });
345
+
346
+ it("ctx.ui.input 不存在 → fallback 固定文案(受阻条件)", async () => {
347
+ // makeApprovalCtx 默认无 input → fallback
348
+ const approvalCtx = makeApprovalCtx({ mode: "rpc" });
349
+ const reason = await collectRejectReason(req, approvalCtx);
350
+ expect(reason).toBe("denied via rpc");
351
+ });
352
+
353
+ it("ctx.ui.input 返回空字符串 → fallback(用户跳过)", async () => {
354
+ const approvalCtx = makeApprovalCtx({
355
+ mode: "rpc",
356
+ ui: {
357
+ notify: vi.fn(),
358
+ select: vi.fn(),
359
+ custom: vi.fn(),
360
+ input: vi.fn(() => Promise.resolve(" ")),
361
+ },
362
+ });
363
+ const reason = await collectRejectReason(req, approvalCtx);
364
+ expect(reason).toBe("denied via rpc");
365
+ });
366
+
367
+ it("ctx.ui.input 返回 undefined → fallback(用户取消)", async () => {
368
+ const approvalCtx = makeApprovalCtx({
369
+ mode: "rpc",
370
+ ui: {
371
+ notify: vi.fn(),
372
+ select: vi.fn(),
373
+ custom: vi.fn(),
374
+ input: vi.fn(() => Promise.resolve(undefined)),
375
+ },
376
+ });
377
+ const reason = await collectRejectReason(req, approvalCtx);
378
+ expect(reason).toBe("denied via rpc");
379
+ });
380
+
381
+ it("ctx.ui.input 抛异常 → fail-soft fallback(不阻塞 deny)", async () => {
382
+ const approvalCtx = makeApprovalCtx({
383
+ mode: "rpc",
384
+ ui: {
385
+ notify: vi.fn(),
386
+ select: vi.fn(),
387
+ custom: vi.fn(),
388
+ input: vi.fn(() => Promise.reject(new Error("input UI crashed"))),
389
+ },
390
+ });
391
+ const reason = await collectRejectReason(req, approvalCtx);
392
+ expect(reason).toBe("denied via rpc");
393
+ });
394
+
395
+ it("reason 被去空白(trim)", async () => {
396
+ const approvalCtx = makeApprovalCtx({
397
+ mode: "rpc",
398
+ ui: {
399
+ notify: vi.fn(),
400
+ select: vi.fn(),
401
+ custom: vi.fn(),
402
+ input: vi.fn(() => Promise.resolve(" too dangerous ")),
403
+ },
404
+ });
405
+ const reason = await collectRejectReason(req, approvalCtx);
406
+ expect(reason).toBe("denied via rpc: too dangerous");
407
+ });
408
+ });
409
+
410
+ describe("W6 T9 G3: RPC 分支拒绝时调 input 采集理由", () => {
411
+ it("用户选 Deny + ctx.ui.input 存在 → reason 含真实理由", async () => {
412
+ const approvalCtx = makeApprovalCtx({
413
+ mode: "rpc",
414
+ ui: {
415
+ notify: vi.fn(),
416
+ select: vi.fn(() => Promise.resolve("Deny")),
417
+ custom: vi.fn(),
418
+ input: vi.fn(() => Promise.resolve("not safe for prod")),
419
+ },
420
+ });
421
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
422
+ expect(decision.approved).toBe(false);
423
+ expect(decision.reason).toBe("denied via rpc: not safe for prod");
424
+ expect(approvalCtx.ui.input).toHaveBeenCalledOnce();
425
+ });
426
+
427
+ it("用户选 Deny + ctx.ui.input 缺失 → reason 是 fallback 文案", async () => {
428
+ const approvalCtx = makeApprovalCtx({
429
+ mode: "rpc",
430
+ ui: {
431
+ notify: vi.fn(),
432
+ select: vi.fn(() => Promise.resolve("Deny")),
433
+ custom: vi.fn(),
434
+ },
435
+ });
436
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
437
+ expect(decision.approved).toBe(false);
438
+ expect(decision.reason).toBe("denied via rpc");
439
+ });
440
+
441
+ it("用户选 Approve → 不调 input(approve 不需要理由)", async () => {
442
+ const inputMock = vi.fn(() => Promise.resolve("should-not-be-called"));
443
+ const approvalCtx = makeApprovalCtx({
444
+ mode: "rpc",
445
+ ui: {
446
+ notify: vi.fn(),
447
+ select: vi.fn(() => Promise.resolve("Approve (once)")),
448
+ custom: vi.fn(),
449
+ input: inputMock,
450
+ },
451
+ });
452
+ const decision = await requestUserApproval(req, ctx, undefined, approvalCtx);
453
+ expect(decision.approved).toBe(true);
454
+ expect(inputMock).not.toHaveBeenCalled();
455
+ });
456
+ });