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.
- package/CHANGELOG.md +25 -0
- package/README.md +22 -25
- package/dist/dashboard/file-detail.d.ts +6 -0
- package/dist/dashboard/file-detail.d.ts.map +1 -1
- package/dist/dashboard/file-detail.js +1 -0
- package/dist/dashboard/file-detail.js.map +1 -1
- package/dist/monorepo/project-map.d.ts.map +1 -1
- package/dist/monorepo/project-map.js +135 -6
- package/dist/monorepo/project-map.js.map +1 -1
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +2 -2
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/shared/exclusions.d.ts.map +1 -1
- package/dist/shared/exclusions.js +22 -0
- package/dist/shared/exclusions.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bundle/dashboard/public/index.html +216 -7
- package/plugin/bundle/mcp-server.mjs +145 -31
- package/plugin/bundle/mcp-server.mjs.map +3 -3
- package/plugin/hooks/lib/gatekeeper-rules.mjs +274 -45
- package/plugin/hooks/lib/quality-gate.mjs +3 -0
- package/plugin/package-lock.json +8 -8
- package/plugin/package.json +1 -1
- package/src/dashboard/file-detail.ts +7 -0
- package/src/dashboard/public/index.html +216 -7
- package/src/monorepo/project-map.ts +144 -6
- package/src/scanner/bootstrap.ts +7 -2
- package/src/shared/exclusions.ts +26 -0
- package/src/tests/exclusions.test.ts +53 -0
- package/src/tests/file-detail-api.test.ts +38 -0
- package/src/tests/gatekeeper-rules.test.ts +173 -0
- package/src/tests/project-map.test.ts +216 -0
- 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
|
+
});
|