claude-crap 0.4.5 → 0.4.7

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 (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +22 -25
  3. package/dist/dashboard/file-detail.d.ts +6 -0
  4. package/dist/dashboard/file-detail.d.ts.map +1 -1
  5. package/dist/dashboard/file-detail.js +1 -0
  6. package/dist/dashboard/file-detail.js.map +1 -1
  7. package/dist/monorepo/project-map.d.ts.map +1 -1
  8. package/dist/monorepo/project-map.js +135 -6
  9. package/dist/monorepo/project-map.js.map +1 -1
  10. package/dist/scanner/bootstrap.d.ts.map +1 -1
  11. package/dist/scanner/bootstrap.js +2 -2
  12. package/dist/scanner/bootstrap.js.map +1 -1
  13. package/dist/shared/exclusions.d.ts.map +1 -1
  14. package/dist/shared/exclusions.js +22 -0
  15. package/dist/shared/exclusions.js.map +1 -1
  16. package/package.json +1 -1
  17. package/plugin/.claude-plugin/plugin.json +1 -1
  18. package/plugin/bundle/dashboard/public/index.html +216 -7
  19. package/plugin/bundle/mcp-server.mjs +145 -31
  20. package/plugin/bundle/mcp-server.mjs.map +3 -3
  21. package/plugin/hooks/lib/gatekeeper-rules.mjs +274 -45
  22. package/plugin/hooks/lib/quality-gate.mjs +3 -0
  23. package/plugin/package-lock.json +8 -8
  24. package/plugin/package.json +1 -1
  25. package/src/dashboard/file-detail.ts +7 -0
  26. package/src/dashboard/public/index.html +216 -7
  27. package/src/monorepo/project-map.ts +144 -6
  28. package/src/scanner/bootstrap.ts +7 -2
  29. package/src/shared/exclusions.ts +26 -0
  30. package/src/tests/exclusions.test.ts +53 -0
  31. package/src/tests/file-detail-api.test.ts +38 -0
  32. package/src/tests/gatekeeper-rules.test.ts +173 -0
  33. package/src/tests/project-map.test.ts +216 -0
  34. package/src/tests/workspace-walker.test.ts +94 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Characterization tests for the PreToolUse gatekeeper rule primitives.
3
+ *
4
+ * The rule module exports pure helpers that decide whether a proposed
5
+ * tool call should be blocked. These tests pin three behaviours the
6
+ * helpers must guarantee:
7
+ *
8
+ * 1. Destructive `rm` detection blocks every realistic variant that
9
+ * targets the filesystem root, a critical system directory, or
10
+ * the user's home, while leaving safe project-relative removals
11
+ * (and quoted `rm -rf /` text inside `echo`) untouched.
12
+ * 2. Emitted SARIF rule IDs carry exactly one category prefix
13
+ * (`SONAR-SEC-...` or `SONAR-BASH-...`). No rule entry in the
14
+ * tables may embed the category in its own `id` field — the
15
+ * emitter is the single source of the prefix.
16
+ * 3. The `AKIA...` AWS access-key regex allowlists canonical
17
+ * AWS-published example keys so the gatekeeper does not reject
18
+ * its own documentation and fixtures, while still catching
19
+ * real-shape keys.
20
+ *
21
+ * The tests import the rule module directly so each assertion runs
22
+ * against the pure helper — no subprocess, no stdin parsing — which
23
+ * keeps failure diagnostics tight.
24
+ *
25
+ * NOTE: real-looking AWS key shapes and canonical example keys are
26
+ * constructed from split literals so the source of this test file
27
+ * itself does not match the gatekeeper regex that scans the `content`
28
+ * field of Write/Edit tool calls.
29
+ *
30
+ * @module tests/gatekeeper-rules.test
31
+ */
32
+
33
+ import { describe, it } from "node:test";
34
+ import assert from "node:assert/strict";
35
+
36
+ // The rule module lives under `plugin/hooks/lib/` so the hook entry
37
+ // points (pure JS, zero deps) can load it at runtime without going
38
+ // through the TypeScript build. tsx resolves the .mjs import at
39
+ // test-time; tsc never sees it (plugin/ is outside rootDir).
40
+ // @ts-expect-error — .mjs file, no .d.ts declarations (intentional)
41
+ import {
42
+ findDestructiveBashHit,
43
+ findSecretHits,
44
+ formatSecretRuleId,
45
+ formatBashRuleId,
46
+ HARDCODED_SECRET_PATTERNS,
47
+ DESTRUCTIVE_BASH_PATTERNS,
48
+ } from "../../plugin/hooks/lib/gatekeeper-rules.mjs";
49
+
50
+ // ── Destructive rm detection ──────────────────────────────────────────
51
+
52
+ describe("destructive rm blocks filesystem root, system dirs, and $HOME", () => {
53
+ const MUST_BLOCK: ReadonlyArray<string> = [
54
+ // Filesystem root in every common shape.
55
+ "rm -rf /",
56
+ "rm -rf /",
57
+ "rm -rf / ",
58
+ 'rm -rf "/"',
59
+ "rm -rf /*",
60
+ "sudo rm -rf /",
61
+ "rm --force /",
62
+ "rm --recursive /",
63
+ "rm -rfv /",
64
+ // Critical system directories.
65
+ "rm -rf /usr",
66
+ "rm -rf /etc",
67
+ "rm -rf /var/log",
68
+ "rm -rf /bin",
69
+ "rm -rf /boot",
70
+ "rm -rf /System",
71
+ // Home directory, exact and prefix forms.
72
+ "rm -rf $HOME",
73
+ "rm -rf $HOME/foo",
74
+ "rm -rf $HOME/*",
75
+ "rm -rf ~/",
76
+ "rm -rf ~/stuff",
77
+ "rm -rf ~/*",
78
+ // Shell control operators must not let the target "stick" and bypass
79
+ // classification. `/;echo` was previously a single bare token whose
80
+ // first component failed the system-dir regex, passing the check.
81
+ "rm -rf /;echo done",
82
+ "rm -rf /&&ls",
83
+ "rm -rf /|tee log",
84
+ "rm -rf /;rm -rf /tmp/x",
85
+ // Path-qualified rm must be caught on basename. Absolute, relative,
86
+ // and parent-relative forms are all real invocations the shell honors.
87
+ "/bin/rm -rf /",
88
+ "/usr/bin/rm -rf /",
89
+ "./rm -rf /",
90
+ "../bin/rm -rf /",
91
+ ];
92
+
93
+ for (const cmd of MUST_BLOCK) {
94
+ it(`blocks: ${cmd}`, () => {
95
+ const hit = findDestructiveBashHit(cmd);
96
+ assert.ok(hit, `expected block, got pass for: ${cmd}`);
97
+ });
98
+ }
99
+
100
+ const MUST_PASS: ReadonlyArray<string> = [
101
+ "rm -rf /tmp/foo", // /tmp is scratch, fine
102
+ "rm -rf ./build", // relative path
103
+ "rm -rf node_modules", // named target, no leading /
104
+ "rm -rf dist",
105
+ "echo 'rm -rf /'", // text inside echo, not an rm target
106
+ ];
107
+
108
+ for (const cmd of MUST_PASS) {
109
+ it(`passes: ${cmd}`, () => {
110
+ const hit = findDestructiveBashHit(cmd);
111
+ assert.equal(hit, null, `expected pass, got block for: ${cmd}`);
112
+ });
113
+ }
114
+ });
115
+
116
+ // ── SARIF rule-ID formatting ──────────────────────────────────────────
117
+
118
+ describe("SARIF rule IDs carry exactly one category prefix", () => {
119
+ it("formatSecretRuleId prepends SONAR-SEC exactly once", () => {
120
+ assert.equal(formatSecretRuleId({ id: "AWS" }), "SONAR-SEC-AWS");
121
+ assert.equal(formatSecretRuleId({ id: "PRIVKEY" }), "SONAR-SEC-PRIVKEY");
122
+ });
123
+
124
+ it("formatBashRuleId prepends SONAR-BASH exactly once", () => {
125
+ assert.equal(formatBashRuleId({ id: "RMROOT" }), "SONAR-BASH-RMROOT");
126
+ assert.equal(formatBashRuleId({ id: "RMHOME" }), "SONAR-BASH-RMHOME");
127
+ });
128
+
129
+ it("no secret rule embeds its category in id", () => {
130
+ for (const pat of HARDCODED_SECRET_PATTERNS as ReadonlyArray<{ id: string }>) {
131
+ assert.ok(
132
+ !/^SEC-/.test(pat.id),
133
+ `rule id leaks category (should be stripped): ${pat.id}`,
134
+ );
135
+ }
136
+ });
137
+
138
+ it("no bash rule embeds its category in id", () => {
139
+ for (const pat of DESTRUCTIVE_BASH_PATTERNS as ReadonlyArray<{ id: string }>) {
140
+ assert.ok(
141
+ !/^BASH-/.test(pat.id),
142
+ `rule id leaks category (should be stripped): ${pat.id}`,
143
+ );
144
+ }
145
+ });
146
+ });
147
+
148
+ // ── AWS example-key allowlist ─────────────────────────────────────────
149
+
150
+ describe("canonical AWS example keys are allowlisted, real-shape keys are not", () => {
151
+ // Split so this very file doesn't match the regex it is testing.
152
+ const CANONICAL_EXAMPLE = "AKIA" + "IOSFODNN7" + "EXAMPLE";
153
+ const REAL_SHAPE = "AKIA" + "J7NVPZZZAB12CDEF"; // 20 chars, AKIA + 16 upper-alnum
154
+
155
+ it(`canonical example key '${CANONICAL_EXAMPLE}' is not flagged`, () => {
156
+ const hits = findSecretHits(`aws_access_key_id="${CANONICAL_EXAMPLE}"`);
157
+ const awsHits = hits.filter((h: { id: string }) => h.id === "AWS");
158
+ assert.equal(
159
+ awsHits.length,
160
+ 0,
161
+ `canonical example must not match: got ${JSON.stringify(awsHits)}`,
162
+ );
163
+ });
164
+
165
+ it("real-shape AWS key is still flagged", () => {
166
+ const hits = findSecretHits(`aws_access_key_id="${REAL_SHAPE}"`);
167
+ const awsHits = hits.filter((h: { id: string }) => h.id === "AWS");
168
+ assert.ok(
169
+ awsHits.length >= 1,
170
+ `real-shape key must flag: ${REAL_SHAPE}`,
171
+ );
172
+ });
173
+ });
@@ -299,4 +299,220 @@ describe("persistProjectMap / loadProjectMap", () => {
299
299
  rmSync(dir, { recursive: true, force: true });
300
300
  }
301
301
  });
302
+
303
+ it("non-monorepo map round-trips through persist + load", async () => {
304
+ // list_projects and /api/score rely on projects.json existing after
305
+ // a discovery run, even when the workspace has no sub-projects. The
306
+ // persist + load pair must handle `isMonorepo: false` the same as
307
+ // monorepo maps.
308
+ const dir = makeTmpDir();
309
+ try {
310
+ const original: ProjectMap = {
311
+ generatedAt: new Date().toISOString(),
312
+ workspaceRoot: dir,
313
+ isMonorepo: false,
314
+ projects: [],
315
+ };
316
+
317
+ await persistProjectMap(original, dir);
318
+ const loaded = loadProjectMap(dir);
319
+
320
+ assert.ok(loaded !== null, "non-monorepo map did not persist");
321
+ assert.equal(loaded.isMonorepo, false);
322
+ assert.deepEqual(loaded, original);
323
+ } finally {
324
+ rmSync(dir, { recursive: true, force: true });
325
+ }
326
+ });
327
+ });
328
+
329
+ // ── .slnx / Directory.Build.props detection ──────────────────────────
330
+
331
+ describe("discoverProjectMap — .NET project markers", () => {
332
+ it(".slnx (.NET 9 XML solution) marks csharp", async () => {
333
+ const dir = makeTmpDir();
334
+ try {
335
+ mkdirSync(join(dir, "apps", "api"), { recursive: true });
336
+ writeFileSync(join(dir, "apps", "api", "MyApp.slnx"), "<Solution></Solution>");
337
+
338
+ const map = await discoverProjectMap(dir);
339
+ const api = findProject(map, "apps/api");
340
+ assert.equal(api.type, "csharp", "expected .slnx to classify as csharp");
341
+ assert.equal(api.scanner, "dotnet_format");
342
+ } finally {
343
+ rmSync(dir, { recursive: true, force: true });
344
+ }
345
+ });
346
+
347
+ it("Directory.Build.props alone marks csharp", async () => {
348
+ const dir = makeTmpDir();
349
+ try {
350
+ mkdirSync(join(dir, "apps", "shared-lib"), { recursive: true });
351
+ writeFileSync(
352
+ join(dir, "apps", "shared-lib", "Directory.Build.props"),
353
+ "<Project></Project>",
354
+ );
355
+
356
+ const map = await discoverProjectMap(dir);
357
+ const p = findProject(map, "apps/shared-lib");
358
+ assert.equal(p.type, "csharp", "Directory.Build.props should classify as csharp");
359
+ } finally {
360
+ rmSync(dir, { recursive: true, force: true });
361
+ }
362
+ });
363
+ });
364
+
365
+ // ── pnpm-workspace.yaml discovery ────────────────────────────────────
366
+
367
+ describe("discoverProjectMap — pnpm-workspace.yaml", () => {
368
+ it("reads pnpm-workspace.yaml and discovers declared packages", async () => {
369
+ const dir = makeTmpDir();
370
+ try {
371
+ // Non-conventional parent dir that the built-in apps/packages scan
372
+ // would never find. pnpm-workspace must be the only source of truth.
373
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "root" }));
374
+ writeFileSync(
375
+ join(dir, "pnpm-workspace.yaml"),
376
+ ["packages:", " - \"tooling/*\"", ""].join("\n"),
377
+ );
378
+
379
+ mkdirSync(join(dir, "tooling", "cli"), { recursive: true });
380
+ writeFileSync(join(dir, "tooling", "cli", "package.json"), JSON.stringify({ name: "cli" }));
381
+ writeFileSync(join(dir, "tooling", "cli", "tsconfig.json"), "{}");
382
+
383
+ const map = await discoverProjectMap(dir);
384
+ const cli = findProject(map, "tooling/cli");
385
+ assert.equal(cli.type, "typescript");
386
+ } finally {
387
+ rmSync(dir, { recursive: true, force: true });
388
+ }
389
+ });
390
+
391
+ it("handles plain-path entries in pnpm-workspace.yaml", async () => {
392
+ const dir = makeTmpDir();
393
+ try {
394
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "root" }));
395
+ writeFileSync(
396
+ join(dir, "pnpm-workspace.yaml"),
397
+ ["packages:", " - 'clients/mobile'", ""].join("\n"),
398
+ );
399
+
400
+ mkdirSync(join(dir, "clients", "mobile"), { recursive: true });
401
+ writeFileSync(
402
+ join(dir, "clients", "mobile", "package.json"),
403
+ JSON.stringify({ name: "mobile" }),
404
+ );
405
+
406
+ const map = await discoverProjectMap(dir);
407
+ const mobile = findProject(map, "clients/mobile");
408
+ assert.equal(mobile.type, "javascript"); // no tsconfig → js
409
+ } finally {
410
+ rmSync(dir, { recursive: true, force: true });
411
+ }
412
+ });
413
+
414
+ it("falls back gracefully when pnpm-workspace.yaml is malformed", async () => {
415
+ const dir = makeTmpDir();
416
+ try {
417
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "root" }));
418
+ writeFileSync(
419
+ join(dir, "pnpm-workspace.yaml"),
420
+ "this: is: not: actually: valid: yaml\n",
421
+ );
422
+
423
+ // Must not throw, and must fall back to non-monorepo with zero
424
+ // sub-projects so downstream scans do not try to walk phantom paths.
425
+ const map = await discoverProjectMap(dir);
426
+ assert.equal(map.isMonorepo, false);
427
+ assert.equal(map.projects.length, 0);
428
+ } finally {
429
+ rmSync(dir, { recursive: true, force: true });
430
+ }
431
+ });
432
+
433
+ it("preserves literal '#' inside a quoted pnpm-workspace entry", async () => {
434
+ const dir = makeTmpDir();
435
+ try {
436
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "root" }));
437
+ // Quoted entry with a '#' — naive comment stripping would truncate
438
+ // it to `"packages/` and the project would silently disappear.
439
+ writeFileSync(
440
+ join(dir, "pnpm-workspace.yaml"),
441
+ ["packages:", " - \"tooling/#oddly-named\"", ""].join("\n"),
442
+ );
443
+
444
+ mkdirSync(join(dir, "tooling", "#oddly-named"), { recursive: true });
445
+ writeFileSync(
446
+ join(dir, "tooling", "#oddly-named", "package.json"),
447
+ JSON.stringify({ name: "oddly" }),
448
+ );
449
+
450
+ const map = await discoverProjectMap(dir);
451
+ const entry = findProject(map, "tooling/#oddly-named");
452
+ assert.equal(entry.type, "javascript");
453
+ } finally {
454
+ rmSync(dir, { recursive: true, force: true });
455
+ }
456
+ });
457
+ });
458
+
459
+ // ── Workspace containment guard ──────────────────────────────────────
460
+
461
+ describe("discoverProjectMap — workspace containment", () => {
462
+ it("drops pnpm-workspace patterns that escape the workspace root", async () => {
463
+ // Sibling directory outside the workspace — if the containment
464
+ // guard fails, the walker would scan it and inflate the TDR.
465
+ const parent = makeTmpDir();
466
+ try {
467
+ const workspace = join(parent, "workspace");
468
+ const sibling = join(parent, "sibling");
469
+ mkdirSync(join(sibling, "pkg"), { recursive: true });
470
+ writeFileSync(join(sibling, "pkg", "package.json"), JSON.stringify({ name: "escapee" }));
471
+
472
+ mkdirSync(workspace, { recursive: true });
473
+ writeFileSync(join(workspace, "package.json"), JSON.stringify({ name: "root" }));
474
+ writeFileSync(
475
+ join(workspace, "pnpm-workspace.yaml"),
476
+ ["packages:", " - \"../sibling/*\"", ""].join("\n"),
477
+ );
478
+
479
+ const map = await discoverProjectMap(workspace);
480
+
481
+ const escapee = map.projects.find((p) => p.path.includes("sibling"));
482
+ assert.equal(
483
+ escapee,
484
+ undefined,
485
+ `expected no project outside workspace, got: ${JSON.stringify(escapee)}`,
486
+ );
487
+ } finally {
488
+ rmSync(parent, { recursive: true, force: true });
489
+ }
490
+ });
491
+ });
492
+
493
+ // ── projectDirs + .NET-only project marker ───────────────────────────
494
+
495
+ describe("discoverProjectMap — configured projectDirs with .NET-only markers", () => {
496
+ it("treats a user-configured directory holding only a .slnx as a project", async () => {
497
+ const dir = makeTmpDir();
498
+ try {
499
+ // Configured projectDir pointing at a directory whose only marker
500
+ // is an .slnx solution file — the single-filename PROJECT_MARKERS
501
+ // list alone would miss this and the dir would be scanned one
502
+ // level deep as if it were a parent directory.
503
+ mkdirSync(join(dir, "services", "api"), { recursive: true });
504
+ writeFileSync(
505
+ join(dir, "services", "api", "Api.slnx"),
506
+ "<Solution></Solution>",
507
+ );
508
+
509
+ const map = await discoverProjectMap(dir, {
510
+ projectDirs: ["services/api"],
511
+ });
512
+ const api = findProject(map, "services/api");
513
+ assert.equal(api.type, "csharp");
514
+ } finally {
515
+ rmSync(dir, { recursive: true, force: true });
516
+ }
517
+ });
302
518
  });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Integration tests for the workspace walker's default exclusions.
3
+ *
4
+ * The LOC denominator of the Technical Debt Ratio must reflect code
5
+ * the team actually writes, not build outputs. Real repositories
6
+ * commonly host Electron-builder outputs (`dist-electron/`,
7
+ * `release/`), .NET outputs (`bin/`, `obj/`, `publish/`), CI outputs
8
+ * (`artifacts/`), and Xcode caches (`DerivedData/`, `Pods/`,
9
+ * `Carthage/`) — all of which would inflate LOC if walked.
10
+ *
11
+ * This suite creates a scratch workspace that seeds a single real
12
+ * source file (`src/app.ts`) alongside populated build-artefact
13
+ * directories, then asserts the walker only counts the real source.
14
+ *
15
+ * @module tests/workspace-walker.test
16
+ */
17
+
18
+ import { describe, it } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { tmpdir } from "node:os";
23
+
24
+ import { estimateWorkspaceLoc } from "../metrics/workspace-walker.js";
25
+
26
+ function makeTmpDir(): string {
27
+ return mkdtempSync(join(tmpdir(), "crap-walker-"));
28
+ }
29
+
30
+ function touch(absPath: string, content = ""): void {
31
+ mkdirSync(join(absPath, ".."), { recursive: true });
32
+ writeFileSync(absPath, content, "utf8");
33
+ }
34
+
35
+ describe("estimateWorkspaceLoc — default skip dirs exclude common build artefacts", () => {
36
+ it("skips Electron/Xcode/.NET/CI build outputs by default", async () => {
37
+ const dir = makeTmpDir();
38
+ try {
39
+ // Single real source file — the only thing that must count.
40
+ touch(join(dir, "src/app.ts"), "export const x = 1;\n");
41
+
42
+ // Noise that prior versions would have counted.
43
+ touch(join(dir, "dist-electron/main.js"), "console.log('electron');\n");
44
+ touch(join(dir, "release/MyApp/contents.js"), "console.log('release');\n");
45
+ touch(join(dir, "artifacts/build.js"), "console.log('ci');\n");
46
+ touch(join(dir, "publish/netcoreapp.dll.js"), "// dotnet publish\n");
47
+ touch(join(dir, "bin/Debug/net8.0/app.cs"), "// stale bin output\n");
48
+ touch(join(dir, "obj/project.assets.ts"), "export {};\n");
49
+ touch(join(dir, "Pods/PodStub.swift"), "struct S {}\n");
50
+ touch(join(dir, "DerivedData/ModuleCache/foo.swift"), "struct F {}\n");
51
+ touch(join(dir, "Carthage/Build/cache.swift"), "struct C {}\n");
52
+
53
+ const result = await estimateWorkspaceLoc(dir);
54
+
55
+ assert.equal(
56
+ result.fileCount,
57
+ 1,
58
+ `expected only src/app.ts to count, got fileCount=${result.fileCount}`,
59
+ );
60
+ } finally {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ it("skips generated test coverage report bundles", async () => {
66
+ // Regression: the dashboard flagged GanttLite.Server/coverage-report/main.js
67
+ // (ReportGenerator output) as the hottest file in the project with
68
+ // four CC-80+ errors, all coming from minified `main.js` / `class.js`.
69
+ // The walker must not descend into any coverage-report variant.
70
+ const dir = makeTmpDir();
71
+ try {
72
+ touch(join(dir, "src/real.ts"), "export const x = 1;\n");
73
+
74
+ touch(join(dir, "coverage-report/main.js"), "function gG(){return 1}\n");
75
+ touch(join(dir, "coverage-report/class.js"), "function N(){return 1}\n");
76
+ touch(join(dir, "CoverageReport/index.js"), "function a(){}\n");
77
+ touch(join(dir, "coveragereport/bundle.js"), "function b(){}\n");
78
+ touch(join(dir, "TestResults/report.js"), "function c(){}\n");
79
+ touch(join(dir, "cobertura/cobertura.js"), "function d(){}\n");
80
+ touch(join(dir, "lcov-report/prettify.js"), "function e(){}\n");
81
+ touch(join(dir, "htmlcov/pycov.js"), "function f(){}\n");
82
+
83
+ const result = await estimateWorkspaceLoc(dir);
84
+
85
+ assert.equal(
86
+ result.fileCount,
87
+ 1,
88
+ `coverage-report bundles leaked into walk — fileCount=${result.fileCount}`,
89
+ );
90
+ } finally {
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+ });