claude-crap 0.3.8 → 0.4.0
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 +33 -0
- package/README.md +69 -27
- package/dist/adapters/common.d.ts +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +1 -1
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/dotnet-format.d.ts +35 -0
- package/dist/adapters/dotnet-format.d.ts.map +1 -0
- package/dist/adapters/dotnet-format.js +96 -0
- package/dist/adapters/dotnet-format.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +4 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/crap-config.d.ts +2 -0
- package/dist/crap-config.d.ts.map +1 -1
- package/dist/crap-config.js +19 -4
- package/dist/crap-config.js.map +1 -1
- package/dist/dashboard/server.js +1 -1
- package/dist/index.js +74 -5
- package/dist/index.js.map +1 -1
- package/dist/monorepo/project-map.d.ts +112 -0
- package/dist/monorepo/project-map.d.ts.map +1 -0
- package/dist/monorepo/project-map.js +384 -0
- package/dist/monorepo/project-map.js.map +1 -0
- package/dist/scanner/bootstrap.d.ts.map +1 -1
- package/dist/scanner/bootstrap.js +6 -1
- package/dist/scanner/bootstrap.js.map +1 -1
- package/dist/scanner/detector.d.ts.map +1 -1
- package/dist/scanner/detector.js +7 -2
- package/dist/scanner/detector.js.map +1 -1
- package/dist/scanner/runner.d.ts.map +1 -1
- package/dist/scanner/runner.js +13 -0
- package/dist/scanner/runner.js.map +1 -1
- package/dist/schemas/tool-schemas.d.ts +16 -1
- package/dist/schemas/tool-schemas.d.ts.map +1 -1
- package/dist/schemas/tool-schemas.js +16 -1
- package/dist/schemas/tool-schemas.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +37 -0
- package/plugin/bundle/mcp-server.mjs +395 -29
- package/plugin/bundle/mcp-server.mjs.map +4 -4
- package/plugin/package-lock.json +2 -2
- package/plugin/package.json +1 -1
- package/src/adapters/common.ts +1 -1
- package/src/adapters/dotnet-format.ts +125 -0
- package/src/adapters/index.ts +4 -0
- package/src/crap-config.ts +27 -4
- package/src/dashboard/server.ts +1 -1
- package/src/index.ts +88 -5
- package/src/monorepo/project-map.ts +476 -0
- package/src/scanner/bootstrap.ts +7 -1
- package/src/scanner/detector.ts +7 -2
- package/src/scanner/runner.ts +13 -0
- package/src/schemas/tool-schemas.ts +17 -1
- package/src/tests/adapters/dispatch.test.ts +1 -1
- package/src/tests/auto-scan.test.ts +2 -2
- package/src/tests/boot-monorepo.test.ts +804 -0
- package/src/tests/boot-scanner-detection.test.ts +692 -0
- package/src/tests/boot-single-project.test.ts +780 -0
- package/src/tests/integration/mcp-server.integration.test.ts +2 -1
- package/src/tests/project-map.test.ts +302 -0
- package/src/tests/scanner-detector.test.ts +4 -4
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time monorepo discovery tests.
|
|
3
|
+
*
|
|
4
|
+
* Exercises {@link discoverProjectMap}, {@link persistProjectMap}, and
|
|
5
|
+
* {@link loadProjectMap} across the full range of workspace layouts the
|
|
6
|
+
* plugin supports at boot: npm workspaces (array, object, and glob
|
|
7
|
+
* forms), pnpm-style directory conventions, user-configured projectDirs,
|
|
8
|
+
* polyglot monorepos, project-type detection priority, persistence
|
|
9
|
+
* round-trips, and edge cases such as hidden directories and duplicates.
|
|
10
|
+
*
|
|
11
|
+
* Every test uses a fresh `mkdtempSync` directory and removes it in a
|
|
12
|
+
* `finally` block so failures leave no artefacts on disk.
|
|
13
|
+
*
|
|
14
|
+
* @module tests/boot-monorepo.test
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import {
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
discoverProjectMap,
|
|
31
|
+
persistProjectMap,
|
|
32
|
+
loadProjectMap,
|
|
33
|
+
type ProjectMap,
|
|
34
|
+
type ProjectEntry,
|
|
35
|
+
} from "../monorepo/project-map.js";
|
|
36
|
+
|
|
37
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Create and return a fresh temporary directory for one test. */
|
|
40
|
+
function makeTmpDir(): string {
|
|
41
|
+
return mkdtempSync(join(tmpdir(), "crap-boot-monorepo-"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write `content` to `absPath`, creating every ancestor directory first.
|
|
46
|
+
* Passing no content produces an empty file (sufficient for marker detection).
|
|
47
|
+
*/
|
|
48
|
+
function touch(absPath: string, content = ""): void {
|
|
49
|
+
mkdirSync(join(absPath, ".."), { recursive: true });
|
|
50
|
+
writeFileSync(absPath, content, "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return the ProjectEntry whose `path` equals `relPath`, or throw an
|
|
55
|
+
* assertion error listing all discovered paths to help diagnose failures.
|
|
56
|
+
*/
|
|
57
|
+
function findProject(map: ProjectMap, relPath: string): ProjectEntry {
|
|
58
|
+
const entry = map.projects.find((p) => p.path === relPath);
|
|
59
|
+
assert.ok(
|
|
60
|
+
entry,
|
|
61
|
+
`expected project at "${relPath}" — found: [${map.projects.map((p) => p.path).join(", ")}]`,
|
|
62
|
+
);
|
|
63
|
+
return entry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── npm workspaces layouts ─────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("npm workspaces — array format", () => {
|
|
69
|
+
it("discovers exactly the two explicitly listed workspace paths", async () => {
|
|
70
|
+
const dir = makeTmpDir();
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(dir, "package.json"),
|
|
74
|
+
JSON.stringify({ name: "root", workspaces: ["apps/web", "apps/api"] }),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
mkdirSync(join(dir, "apps", "web"), { recursive: true });
|
|
78
|
+
writeFileSync(
|
|
79
|
+
join(dir, "apps", "web", "package.json"),
|
|
80
|
+
JSON.stringify({ name: "web" }),
|
|
81
|
+
);
|
|
82
|
+
writeFileSync(join(dir, "apps", "web", "tsconfig.json"), "{}");
|
|
83
|
+
|
|
84
|
+
mkdirSync(join(dir, "apps", "api"), { recursive: true });
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(dir, "apps", "api", "package.json"),
|
|
87
|
+
JSON.stringify({ name: "api" }),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const map = await discoverProjectMap(dir);
|
|
91
|
+
|
|
92
|
+
assert.equal(map.isMonorepo, true);
|
|
93
|
+
assert.equal(map.projects.length, 2);
|
|
94
|
+
|
|
95
|
+
const web = findProject(map, "apps/web");
|
|
96
|
+
assert.equal(web.name, "web");
|
|
97
|
+
assert.equal(web.type, "typescript");
|
|
98
|
+
assert.equal(web.scanner, "eslint");
|
|
99
|
+
|
|
100
|
+
const api = findProject(map, "apps/api");
|
|
101
|
+
assert.equal(api.name, "api");
|
|
102
|
+
assert.equal(api.type, "javascript");
|
|
103
|
+
assert.equal(api.scanner, "eslint");
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("npm workspaces — object { packages: [...] } format", () => {
|
|
111
|
+
it("resolves packages listed under the 'packages' key", async () => {
|
|
112
|
+
const dir = makeTmpDir();
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(dir, "package.json"),
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
name: "root",
|
|
118
|
+
workspaces: { packages: ["packages/*"] },
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
mkdirSync(join(dir, "packages", "ui"), { recursive: true });
|
|
123
|
+
writeFileSync(
|
|
124
|
+
join(dir, "packages", "ui", "package.json"),
|
|
125
|
+
JSON.stringify({ name: "ui" }),
|
|
126
|
+
);
|
|
127
|
+
writeFileSync(join(dir, "packages", "ui", "tsconfig.json"), "{}");
|
|
128
|
+
|
|
129
|
+
mkdirSync(join(dir, "packages", "utils"), { recursive: true });
|
|
130
|
+
writeFileSync(
|
|
131
|
+
join(dir, "packages", "utils", "package.json"),
|
|
132
|
+
JSON.stringify({ name: "utils" }),
|
|
133
|
+
);
|
|
134
|
+
writeFileSync(join(dir, "packages", "utils", "tsconfig.json"), "{}");
|
|
135
|
+
|
|
136
|
+
const map = await discoverProjectMap(dir);
|
|
137
|
+
|
|
138
|
+
assert.equal(map.isMonorepo, true);
|
|
139
|
+
assert.equal(map.projects.length, 2);
|
|
140
|
+
findProject(map, "packages/ui");
|
|
141
|
+
findProject(map, "packages/utils");
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(dir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("npm workspaces — glob pattern 'packages/*'", () => {
|
|
149
|
+
it("expands the glob and returns one entry per matching subdirectory", async () => {
|
|
150
|
+
const dir = makeTmpDir();
|
|
151
|
+
try {
|
|
152
|
+
writeFileSync(
|
|
153
|
+
join(dir, "package.json"),
|
|
154
|
+
JSON.stringify({ name: "root", workspaces: ["packages/*"] }),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
for (const pkg of ["alpha", "beta", "gamma"]) {
|
|
158
|
+
mkdirSync(join(dir, "packages", pkg), { recursive: true });
|
|
159
|
+
writeFileSync(
|
|
160
|
+
join(dir, "packages", pkg, "package.json"),
|
|
161
|
+
JSON.stringify({ name: pkg }),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const map = await discoverProjectMap(dir);
|
|
166
|
+
|
|
167
|
+
assert.equal(map.isMonorepo, true);
|
|
168
|
+
assert.equal(map.projects.length, 3);
|
|
169
|
+
findProject(map, "packages/alpha");
|
|
170
|
+
findProject(map, "packages/beta");
|
|
171
|
+
findProject(map, "packages/gamma");
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(dir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("pnpm-style workspace — no package.json workspaces field", () => {
|
|
179
|
+
it("falls back to built-in directory scan and finds projects in apps/", async () => {
|
|
180
|
+
const dir = makeTmpDir();
|
|
181
|
+
try {
|
|
182
|
+
// Root package.json has no workspaces field — simulates pnpm layout
|
|
183
|
+
writeFileSync(
|
|
184
|
+
join(dir, "package.json"),
|
|
185
|
+
JSON.stringify({ name: "root" }),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
mkdirSync(join(dir, "apps", "frontend"), { recursive: true });
|
|
189
|
+
writeFileSync(
|
|
190
|
+
join(dir, "apps", "frontend", "package.json"),
|
|
191
|
+
JSON.stringify({ name: "frontend" }),
|
|
192
|
+
);
|
|
193
|
+
writeFileSync(join(dir, "apps", "frontend", "tsconfig.json"), "{}");
|
|
194
|
+
|
|
195
|
+
mkdirSync(join(dir, "apps", "backend"), { recursive: true });
|
|
196
|
+
writeFileSync(
|
|
197
|
+
join(dir, "apps", "backend", "pyproject.toml"),
|
|
198
|
+
"[project]\nname = \"backend\"\n",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const map = await discoverProjectMap(dir);
|
|
202
|
+
|
|
203
|
+
assert.equal(map.isMonorepo, true);
|
|
204
|
+
assert.ok(map.projects.length >= 2);
|
|
205
|
+
|
|
206
|
+
const frontend = findProject(map, "apps/frontend");
|
|
207
|
+
assert.equal(frontend.type, "typescript");
|
|
208
|
+
|
|
209
|
+
const backend = findProject(map, "apps/backend");
|
|
210
|
+
assert.equal(backend.type, "python");
|
|
211
|
+
} finally {
|
|
212
|
+
rmSync(dir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Built-in directory conventions ────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
describe("built-in directory scan — apps/ only", () => {
|
|
220
|
+
it("discovers both subdirs under apps/", async () => {
|
|
221
|
+
const dir = makeTmpDir();
|
|
222
|
+
try {
|
|
223
|
+
mkdirSync(join(dir, "apps", "web"), { recursive: true });
|
|
224
|
+
touch(join(dir, "apps", "web", "package.json"), "{}");
|
|
225
|
+
|
|
226
|
+
mkdirSync(join(dir, "apps", "mobile"), { recursive: true });
|
|
227
|
+
touch(
|
|
228
|
+
join(dir, "apps", "mobile", "pubspec.yaml"),
|
|
229
|
+
"name: mobile\nenvironment:\n sdk: '>=3.0.0 <4.0.0'\n",
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const map = await discoverProjectMap(dir);
|
|
233
|
+
|
|
234
|
+
assert.equal(map.isMonorepo, true);
|
|
235
|
+
assert.equal(map.projects.length, 2);
|
|
236
|
+
findProject(map, "apps/web");
|
|
237
|
+
findProject(map, "apps/mobile");
|
|
238
|
+
} finally {
|
|
239
|
+
rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("built-in directory scan — packages/ only", () => {
|
|
245
|
+
it("discovers both subdirs under packages/", async () => {
|
|
246
|
+
const dir = makeTmpDir();
|
|
247
|
+
try {
|
|
248
|
+
mkdirSync(join(dir, "packages", "core"), { recursive: true });
|
|
249
|
+
touch(join(dir, "packages", "core", "package.json"), "{}");
|
|
250
|
+
touch(join(dir, "packages", "core", "tsconfig.json"), "{}");
|
|
251
|
+
|
|
252
|
+
mkdirSync(join(dir, "packages", "ui"), { recursive: true });
|
|
253
|
+
touch(join(dir, "packages", "ui", "package.json"), "{}");
|
|
254
|
+
touch(join(dir, "packages", "ui", "tsconfig.json"), "{}");
|
|
255
|
+
|
|
256
|
+
const map = await discoverProjectMap(dir);
|
|
257
|
+
|
|
258
|
+
assert.equal(map.isMonorepo, true);
|
|
259
|
+
assert.equal(map.projects.length, 2);
|
|
260
|
+
findProject(map, "packages/core");
|
|
261
|
+
findProject(map, "packages/ui");
|
|
262
|
+
} finally {
|
|
263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("built-in directory scan — libs/ only", () => {
|
|
269
|
+
it("discovers the single subdir under libs/", async () => {
|
|
270
|
+
const dir = makeTmpDir();
|
|
271
|
+
try {
|
|
272
|
+
mkdirSync(join(dir, "libs", "shared"), { recursive: true });
|
|
273
|
+
touch(join(dir, "libs", "shared", "package.json"), "{}");
|
|
274
|
+
touch(join(dir, "libs", "shared", "tsconfig.json"), "{}");
|
|
275
|
+
|
|
276
|
+
const map = await discoverProjectMap(dir);
|
|
277
|
+
|
|
278
|
+
assert.equal(map.isMonorepo, true);
|
|
279
|
+
assert.equal(map.projects.length, 1);
|
|
280
|
+
const shared = findProject(map, "libs/shared");
|
|
281
|
+
assert.equal(shared.type, "typescript");
|
|
282
|
+
} finally {
|
|
283
|
+
rmSync(dir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("built-in directory scan — modules/ and services/", () => {
|
|
289
|
+
it("discovers one project per conventional directory", async () => {
|
|
290
|
+
const dir = makeTmpDir();
|
|
291
|
+
try {
|
|
292
|
+
mkdirSync(join(dir, "modules", "auth"), { recursive: true });
|
|
293
|
+
touch(join(dir, "modules", "auth", "package.json"), "{}");
|
|
294
|
+
|
|
295
|
+
mkdirSync(join(dir, "services", "gateway"), { recursive: true });
|
|
296
|
+
touch(join(dir, "services", "gateway", "package.json"), "{}");
|
|
297
|
+
touch(join(dir, "services", "gateway", "tsconfig.json"), "{}");
|
|
298
|
+
|
|
299
|
+
const map = await discoverProjectMap(dir);
|
|
300
|
+
|
|
301
|
+
assert.equal(map.isMonorepo, true);
|
|
302
|
+
assert.equal(map.projects.length, 2);
|
|
303
|
+
findProject(map, "modules/auth");
|
|
304
|
+
findProject(map, "services/gateway");
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(dir, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("built-in directory scan — mixed apps/ and packages/", () => {
|
|
312
|
+
it("returns 3 projects across both conventional directories", async () => {
|
|
313
|
+
const dir = makeTmpDir();
|
|
314
|
+
try {
|
|
315
|
+
mkdirSync(join(dir, "apps", "web"), { recursive: true });
|
|
316
|
+
touch(join(dir, "apps", "web", "package.json"), "{}");
|
|
317
|
+
touch(join(dir, "apps", "web", "tsconfig.json"), "{}");
|
|
318
|
+
|
|
319
|
+
mkdirSync(join(dir, "packages", "ui"), { recursive: true });
|
|
320
|
+
touch(join(dir, "packages", "ui", "package.json"), "{}");
|
|
321
|
+
touch(join(dir, "packages", "ui", "tsconfig.json"), "{}");
|
|
322
|
+
|
|
323
|
+
mkdirSync(join(dir, "packages", "utils"), { recursive: true });
|
|
324
|
+
touch(join(dir, "packages", "utils", "package.json"), "{}");
|
|
325
|
+
touch(join(dir, "packages", "utils", "tsconfig.json"), "{}");
|
|
326
|
+
|
|
327
|
+
const map = await discoverProjectMap(dir);
|
|
328
|
+
|
|
329
|
+
assert.equal(map.isMonorepo, true);
|
|
330
|
+
assert.equal(map.projects.length, 3);
|
|
331
|
+
findProject(map, "apps/web");
|
|
332
|
+
findProject(map, "packages/ui");
|
|
333
|
+
findProject(map, "packages/utils");
|
|
334
|
+
} finally {
|
|
335
|
+
rmSync(dir, { recursive: true, force: true });
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ── Custom projectDirs ─────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
describe("custom projectDirs — flat root layout", () => {
|
|
343
|
+
it("discovers typescript and python projects by directory name", async () => {
|
|
344
|
+
const dir = makeTmpDir();
|
|
345
|
+
try {
|
|
346
|
+
mkdirSync(join(dir, "frontend"), { recursive: true });
|
|
347
|
+
touch(
|
|
348
|
+
join(dir, "frontend", "package.json"),
|
|
349
|
+
JSON.stringify({ name: "frontend" }),
|
|
350
|
+
);
|
|
351
|
+
touch(join(dir, "frontend", "tsconfig.json"), "{}");
|
|
352
|
+
|
|
353
|
+
mkdirSync(join(dir, "backend"), { recursive: true });
|
|
354
|
+
touch(
|
|
355
|
+
join(dir, "backend", "pyproject.toml"),
|
|
356
|
+
"[project]\nname = \"backend\"\n",
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const map = await discoverProjectMap(dir, {
|
|
360
|
+
projectDirs: ["frontend", "backend"],
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
assert.equal(map.isMonorepo, true);
|
|
364
|
+
assert.equal(map.projects.length, 2);
|
|
365
|
+
|
|
366
|
+
const frontend = findProject(map, "frontend");
|
|
367
|
+
assert.equal(frontend.type, "typescript");
|
|
368
|
+
assert.equal(frontend.scanner, "eslint");
|
|
369
|
+
|
|
370
|
+
const backend = findProject(map, "backend");
|
|
371
|
+
assert.equal(backend.type, "python");
|
|
372
|
+
assert.equal(backend.scanner, "bandit");
|
|
373
|
+
} finally {
|
|
374
|
+
rmSync(dir, { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("custom projectDirs — direct project path", () => {
|
|
380
|
+
it("treats the path as a single project when it has a project marker", async () => {
|
|
381
|
+
const dir = makeTmpDir();
|
|
382
|
+
try {
|
|
383
|
+
mkdirSync(join(dir, "tools", "cli"), { recursive: true });
|
|
384
|
+
touch(
|
|
385
|
+
join(dir, "tools", "cli", "package.json"),
|
|
386
|
+
JSON.stringify({ name: "cli" }),
|
|
387
|
+
);
|
|
388
|
+
touch(join(dir, "tools", "cli", "tsconfig.json"), "{}");
|
|
389
|
+
|
|
390
|
+
const map = await discoverProjectMap(dir, {
|
|
391
|
+
projectDirs: ["tools/cli"],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
assert.equal(map.isMonorepo, true);
|
|
395
|
+
assert.equal(map.projects.length, 1);
|
|
396
|
+
|
|
397
|
+
const cli = findProject(map, "tools/cli");
|
|
398
|
+
assert.equal(cli.name, "cli");
|
|
399
|
+
assert.equal(cli.type, "typescript");
|
|
400
|
+
} finally {
|
|
401
|
+
rmSync(dir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("custom projectDirs — nested custom dirs", () => {
|
|
407
|
+
it("discovers both deeply nested paths", async () => {
|
|
408
|
+
const dir = makeTmpDir();
|
|
409
|
+
try {
|
|
410
|
+
mkdirSync(join(dir, "domain", "billing"), { recursive: true });
|
|
411
|
+
touch(
|
|
412
|
+
join(dir, "domain", "billing", "package.json"),
|
|
413
|
+
JSON.stringify({ name: "billing" }),
|
|
414
|
+
);
|
|
415
|
+
touch(join(dir, "domain", "billing", "tsconfig.json"), "{}");
|
|
416
|
+
|
|
417
|
+
mkdirSync(join(dir, "domain", "users"), { recursive: true });
|
|
418
|
+
touch(
|
|
419
|
+
join(dir, "domain", "users", "package.json"),
|
|
420
|
+
JSON.stringify({ name: "users" }),
|
|
421
|
+
);
|
|
422
|
+
touch(join(dir, "domain", "users", "tsconfig.json"), "{}");
|
|
423
|
+
|
|
424
|
+
const map = await discoverProjectMap(dir, {
|
|
425
|
+
projectDirs: ["domain/billing", "domain/users"],
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
assert.equal(map.isMonorepo, true);
|
|
429
|
+
assert.equal(map.projects.length, 2);
|
|
430
|
+
|
|
431
|
+
const billing = findProject(map, "domain/billing");
|
|
432
|
+
assert.equal(billing.type, "typescript");
|
|
433
|
+
|
|
434
|
+
const users = findProject(map, "domain/users");
|
|
435
|
+
assert.equal(users.type, "typescript");
|
|
436
|
+
} finally {
|
|
437
|
+
rmSync(dir, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("custom projectDirs — merged with npm workspaces", () => {
|
|
443
|
+
it("includes both workspace and projectDirs entries without duplicates", async () => {
|
|
444
|
+
const dir = makeTmpDir();
|
|
445
|
+
try {
|
|
446
|
+
writeFileSync(
|
|
447
|
+
join(dir, "package.json"),
|
|
448
|
+
JSON.stringify({ name: "root", workspaces: ["apps/web"] }),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
mkdirSync(join(dir, "apps", "web"), { recursive: true });
|
|
452
|
+
touch(
|
|
453
|
+
join(dir, "apps", "web", "package.json"),
|
|
454
|
+
JSON.stringify({ name: "web" }),
|
|
455
|
+
);
|
|
456
|
+
touch(join(dir, "apps", "web", "tsconfig.json"), "{}");
|
|
457
|
+
|
|
458
|
+
mkdirSync(join(dir, "infra"), { recursive: true });
|
|
459
|
+
touch(
|
|
460
|
+
join(dir, "infra", "package.json"),
|
|
461
|
+
JSON.stringify({ name: "infra" }),
|
|
462
|
+
);
|
|
463
|
+
touch(join(dir, "infra", "tsconfig.json"), "{}");
|
|
464
|
+
|
|
465
|
+
const map = await discoverProjectMap(dir, {
|
|
466
|
+
projectDirs: ["infra"],
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
assert.equal(map.isMonorepo, true);
|
|
470
|
+
|
|
471
|
+
// Both must be present
|
|
472
|
+
findProject(map, "apps/web");
|
|
473
|
+
findProject(map, "infra");
|
|
474
|
+
|
|
475
|
+
// Neither may be duplicated
|
|
476
|
+
const webCount = map.projects.filter((p) => p.path === "apps/web").length;
|
|
477
|
+
const infraCount = map.projects.filter((p) => p.path === "infra").length;
|
|
478
|
+
assert.equal(webCount, 1, "apps/web appeared more than once");
|
|
479
|
+
assert.equal(infraCount, 1, "infra appeared more than once");
|
|
480
|
+
} finally {
|
|
481
|
+
rmSync(dir, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe("custom projectDirs — non-existent directory", () => {
|
|
487
|
+
it("silently skips missing directories and does not throw", async () => {
|
|
488
|
+
const dir = makeTmpDir();
|
|
489
|
+
try {
|
|
490
|
+
// The referenced directory is intentionally never created.
|
|
491
|
+
const map = await discoverProjectMap(dir, {
|
|
492
|
+
projectDirs: ["doesnt-exist"],
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Verify no crash — the map may be empty or have other entries.
|
|
496
|
+
assert.equal(typeof map.isMonorepo, "boolean");
|
|
497
|
+
assert.ok(Array.isArray(map.projects));
|
|
498
|
+
const missing = map.projects.find((p) => p.path === "doesnt-exist");
|
|
499
|
+
assert.equal(missing, undefined, "non-existent dir must not appear");
|
|
500
|
+
} finally {
|
|
501
|
+
rmSync(dir, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ── Polyglot monorepo ──────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
describe("polyglot monorepo — full mixed-technology workspace", () => {
|
|
509
|
+
it("detects typescript, dart, and csharp projects with correct scanners", async () => {
|
|
510
|
+
const dir = makeTmpDir();
|
|
511
|
+
try {
|
|
512
|
+
// Root declares only the JS/TS workspaces; Dart and C# are found via scan.
|
|
513
|
+
writeFileSync(
|
|
514
|
+
join(dir, "package.json"),
|
|
515
|
+
JSON.stringify({
|
|
516
|
+
name: "root",
|
|
517
|
+
workspaces: ["apps/www", "apps/app"],
|
|
518
|
+
}),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// apps/www — TypeScript (npm workspace)
|
|
522
|
+
mkdirSync(join(dir, "apps", "www"), { recursive: true });
|
|
523
|
+
touch(
|
|
524
|
+
join(dir, "apps", "www", "package.json"),
|
|
525
|
+
JSON.stringify({ name: "www" }),
|
|
526
|
+
);
|
|
527
|
+
touch(join(dir, "apps", "www", "tsconfig.json"), "{}");
|
|
528
|
+
|
|
529
|
+
// apps/app — TypeScript (npm workspace)
|
|
530
|
+
mkdirSync(join(dir, "apps", "app"), { recursive: true });
|
|
531
|
+
touch(
|
|
532
|
+
join(dir, "apps", "app", "package.json"),
|
|
533
|
+
JSON.stringify({ name: "app" }),
|
|
534
|
+
);
|
|
535
|
+
touch(join(dir, "apps", "app", "tsconfig.json"), "{}");
|
|
536
|
+
|
|
537
|
+
// apps/mobile — Dart / Flutter (discovered via apps/ scan)
|
|
538
|
+
mkdirSync(join(dir, "apps", "mobile"), { recursive: true });
|
|
539
|
+
touch(
|
|
540
|
+
join(dir, "apps", "mobile", "pubspec.yaml"),
|
|
541
|
+
"name: mobile\nenvironment:\n sdk: '>=3.0.0 <4.0.0'\n",
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
// apps/api — C# (discovered via apps/ scan)
|
|
545
|
+
mkdirSync(join(dir, "apps", "api"), { recursive: true });
|
|
546
|
+
touch(
|
|
547
|
+
join(dir, "apps", "api", "MyApp.csproj"),
|
|
548
|
+
"<Project Sdk=\"Microsoft.NET.Sdk\"></Project>",
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
const map = await discoverProjectMap(dir);
|
|
552
|
+
|
|
553
|
+
assert.equal(map.isMonorepo, true);
|
|
554
|
+
assert.equal(map.projects.length, 4);
|
|
555
|
+
|
|
556
|
+
const www = findProject(map, "apps/www");
|
|
557
|
+
assert.equal(www.type, "typescript");
|
|
558
|
+
assert.equal(www.scanner, "eslint");
|
|
559
|
+
|
|
560
|
+
const app = findProject(map, "apps/app");
|
|
561
|
+
assert.equal(app.type, "typescript");
|
|
562
|
+
assert.equal(app.scanner, "eslint");
|
|
563
|
+
|
|
564
|
+
const mobile = findProject(map, "apps/mobile");
|
|
565
|
+
assert.equal(mobile.type, "dart");
|
|
566
|
+
assert.equal(mobile.scanner, "dart_analyze");
|
|
567
|
+
|
|
568
|
+
const api = findProject(map, "apps/api");
|
|
569
|
+
assert.equal(api.type, "csharp");
|
|
570
|
+
assert.equal(api.scanner, "dotnet_format");
|
|
571
|
+
} finally {
|
|
572
|
+
rmSync(dir, { recursive: true, force: true });
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ── Project type detection ─────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
describe("project type detection — dart wins over javascript", () => {
|
|
580
|
+
it("returns type 'dart' when pubspec.yaml and package.json both exist", async () => {
|
|
581
|
+
const dir = makeTmpDir();
|
|
582
|
+
try {
|
|
583
|
+
mkdirSync(join(dir, "apps", "hybrid"), { recursive: true });
|
|
584
|
+
touch(
|
|
585
|
+
join(dir, "apps", "hybrid", "pubspec.yaml"),
|
|
586
|
+
"name: hybrid\nenvironment:\n sdk: '>=3.0.0 <4.0.0'\n",
|
|
587
|
+
);
|
|
588
|
+
touch(
|
|
589
|
+
join(dir, "apps", "hybrid", "package.json"),
|
|
590
|
+
JSON.stringify({ name: "hybrid" }),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const map = await discoverProjectMap(dir);
|
|
594
|
+
|
|
595
|
+
const hybrid = findProject(map, "apps/hybrid");
|
|
596
|
+
assert.equal(hybrid.type, "dart");
|
|
597
|
+
assert.equal(hybrid.scanner, "dart_analyze");
|
|
598
|
+
} finally {
|
|
599
|
+
rmSync(dir, { recursive: true, force: true });
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("project type detection — typescript wins over javascript", () => {
|
|
605
|
+
it("returns type 'typescript' when both package.json and tsconfig.json are present", async () => {
|
|
606
|
+
const dir = makeTmpDir();
|
|
607
|
+
try {
|
|
608
|
+
mkdirSync(join(dir, "apps", "ts-app"), { recursive: true });
|
|
609
|
+
touch(
|
|
610
|
+
join(dir, "apps", "ts-app", "package.json"),
|
|
611
|
+
JSON.stringify({ name: "ts-app" }),
|
|
612
|
+
);
|
|
613
|
+
touch(join(dir, "apps", "ts-app", "tsconfig.json"), "{}");
|
|
614
|
+
|
|
615
|
+
const map = await discoverProjectMap(dir);
|
|
616
|
+
|
|
617
|
+
const tsApp = findProject(map, "apps/ts-app");
|
|
618
|
+
assert.equal(tsApp.type, "typescript");
|
|
619
|
+
assert.equal(tsApp.scanner, "eslint");
|
|
620
|
+
} finally {
|
|
621
|
+
rmSync(dir, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
describe("project type detection — C# via .csproj file", () => {
|
|
627
|
+
it("returns type 'csharp' and scanner 'dotnet_format'", async () => {
|
|
628
|
+
const dir = makeTmpDir();
|
|
629
|
+
try {
|
|
630
|
+
mkdirSync(join(dir, "apps", "api"), { recursive: true });
|
|
631
|
+
touch(
|
|
632
|
+
join(dir, "apps", "api", "Server.csproj"),
|
|
633
|
+
"<Project Sdk=\"Microsoft.NET.Sdk\"></Project>",
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const map = await discoverProjectMap(dir);
|
|
637
|
+
|
|
638
|
+
const api = findProject(map, "apps/api");
|
|
639
|
+
assert.equal(api.type, "csharp");
|
|
640
|
+
assert.equal(api.scanner, "dotnet_format");
|
|
641
|
+
} finally {
|
|
642
|
+
rmSync(dir, { recursive: true, force: true });
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
describe("project type detection — C# via .sln file", () => {
|
|
648
|
+
it("returns type 'csharp' when only a .sln file is present", async () => {
|
|
649
|
+
const dir = makeTmpDir();
|
|
650
|
+
try {
|
|
651
|
+
mkdirSync(join(dir, "apps", "api"), { recursive: true });
|
|
652
|
+
touch(join(dir, "apps", "api", "Server.sln"), "");
|
|
653
|
+
|
|
654
|
+
const map = await discoverProjectMap(dir);
|
|
655
|
+
|
|
656
|
+
const api = findProject(map, "apps/api");
|
|
657
|
+
assert.equal(api.type, "csharp");
|
|
658
|
+
assert.equal(api.scanner, "dotnet_format");
|
|
659
|
+
} finally {
|
|
660
|
+
rmSync(dir, { recursive: true, force: true });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe("project type detection — Java via build.gradle.kts", () => {
|
|
666
|
+
it("returns type 'java' and scanner 'semgrep'", async () => {
|
|
667
|
+
const dir = makeTmpDir();
|
|
668
|
+
try {
|
|
669
|
+
mkdirSync(join(dir, "apps", "backend"), { recursive: true });
|
|
670
|
+
touch(join(dir, "apps", "backend", "build.gradle.kts"), "");
|
|
671
|
+
|
|
672
|
+
const map = await discoverProjectMap(dir);
|
|
673
|
+
|
|
674
|
+
const backend = findProject(map, "apps/backend");
|
|
675
|
+
assert.equal(backend.type, "java");
|
|
676
|
+
assert.equal(backend.scanner, "semgrep");
|
|
677
|
+
} finally {
|
|
678
|
+
rmSync(dir, { recursive: true, force: true });
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// ── Persistence ────────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
describe("persistProjectMap / loadProjectMap — round-trip", () => {
|
|
686
|
+
it("loading a persisted map produces a value deeply equal to the original", async () => {
|
|
687
|
+
const dir = makeTmpDir();
|
|
688
|
+
try {
|
|
689
|
+
// Build a real ProjectMap via discovery so all fields are populated.
|
|
690
|
+
mkdirSync(join(dir, "apps", "web"), { recursive: true });
|
|
691
|
+
touch(join(dir, "apps", "web", "package.json"), JSON.stringify({ name: "web" }));
|
|
692
|
+
touch(join(dir, "apps", "web", "tsconfig.json"), "{}");
|
|
693
|
+
|
|
694
|
+
mkdirSync(join(dir, "apps", "service"), { recursive: true });
|
|
695
|
+
touch(
|
|
696
|
+
join(dir, "apps", "service", "pyproject.toml"),
|
|
697
|
+
"[project]\nname = \"service\"\n",
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const discovered = await discoverProjectMap(dir);
|
|
701
|
+
await persistProjectMap(discovered, dir);
|
|
702
|
+
|
|
703
|
+
const loaded = loadProjectMap(dir);
|
|
704
|
+
|
|
705
|
+
assert.ok(loaded !== null, "loadProjectMap returned null after persist");
|
|
706
|
+
assert.deepEqual(loaded, discovered);
|
|
707
|
+
} finally {
|
|
708
|
+
rmSync(dir, { recursive: true, force: true });
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe("loadProjectMap — no persisted file", () => {
|
|
714
|
+
it("returns null when the .claude-crap/projects.json file does not exist", () => {
|
|
715
|
+
const dir = makeTmpDir();
|
|
716
|
+
try {
|
|
717
|
+
const result = loadProjectMap(dir);
|
|
718
|
+
assert.equal(result, null);
|
|
719
|
+
} finally {
|
|
720
|
+
rmSync(dir, { recursive: true, force: true });
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// ── Edge cases ─────────────────────────────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
describe("edge case — hidden directories skipped", () => {
|
|
728
|
+
it("does not include a directory whose name starts with '.'", async () => {
|
|
729
|
+
const dir = makeTmpDir();
|
|
730
|
+
try {
|
|
731
|
+
// Hidden directory with a project marker — must be ignored.
|
|
732
|
+
mkdirSync(join(dir, "apps", ".hidden"), { recursive: true });
|
|
733
|
+
touch(
|
|
734
|
+
join(dir, "apps", ".hidden", "package.json"),
|
|
735
|
+
JSON.stringify({ name: "hidden" }),
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
// Visible directory — must be discovered normally.
|
|
739
|
+
mkdirSync(join(dir, "apps", "visible"), { recursive: true });
|
|
740
|
+
touch(
|
|
741
|
+
join(dir, "apps", "visible", "package.json"),
|
|
742
|
+
JSON.stringify({ name: "visible" }),
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
const map = await discoverProjectMap(dir);
|
|
746
|
+
|
|
747
|
+
const hidden = map.projects.find((p) => p.name === ".hidden");
|
|
748
|
+
assert.equal(hidden, undefined, "hidden directory must not be discovered");
|
|
749
|
+
|
|
750
|
+
findProject(map, "apps/visible");
|
|
751
|
+
} finally {
|
|
752
|
+
rmSync(dir, { recursive: true, force: true });
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
describe("edge case — empty apps/ directory", () => {
|
|
758
|
+
it("returns isMonorepo false and an empty projects array", async () => {
|
|
759
|
+
const dir = makeTmpDir();
|
|
760
|
+
try {
|
|
761
|
+
// Create apps/ but leave it empty.
|
|
762
|
+
mkdirSync(join(dir, "apps"), { recursive: true });
|
|
763
|
+
|
|
764
|
+
const map = await discoverProjectMap(dir);
|
|
765
|
+
|
|
766
|
+
assert.equal(map.isMonorepo, false);
|
|
767
|
+
assert.deepEqual(map.projects, []);
|
|
768
|
+
} finally {
|
|
769
|
+
rmSync(dir, { recursive: true, force: true });
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
describe("edge case — duplicate detection", () => {
|
|
775
|
+
it("a project listed in workspaces and also under apps/ appears exactly once", async () => {
|
|
776
|
+
const dir = makeTmpDir();
|
|
777
|
+
try {
|
|
778
|
+
// apps/shared is declared in workspaces AND resides under apps/,
|
|
779
|
+
// which the built-in directory scanner would also traverse.
|
|
780
|
+
writeFileSync(
|
|
781
|
+
join(dir, "package.json"),
|
|
782
|
+
JSON.stringify({ name: "root", workspaces: ["apps/shared"] }),
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
mkdirSync(join(dir, "apps", "shared"), { recursive: true });
|
|
786
|
+
touch(
|
|
787
|
+
join(dir, "apps", "shared", "package.json"),
|
|
788
|
+
JSON.stringify({ name: "shared" }),
|
|
789
|
+
);
|
|
790
|
+
touch(join(dir, "apps", "shared", "tsconfig.json"), "{}");
|
|
791
|
+
|
|
792
|
+
const map = await discoverProjectMap(dir);
|
|
793
|
+
|
|
794
|
+
const matches = map.projects.filter((p) => p.path === "apps/shared");
|
|
795
|
+
assert.equal(
|
|
796
|
+
matches.length,
|
|
797
|
+
1,
|
|
798
|
+
`expected exactly 1 entry for "apps/shared", got ${matches.length}`,
|
|
799
|
+
);
|
|
800
|
+
} finally {
|
|
801
|
+
rmSync(dir, { recursive: true, force: true });
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
});
|