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,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monorepo project discovery and project-map generation.
|
|
3
|
+
*
|
|
4
|
+
* At plugin boot the host can call {@link discoverProjectMap} to walk
|
|
5
|
+
* a workspace, detect every sub-project's language/framework, pick the
|
|
6
|
+
* right scanner, and probe whether that scanner binary is available on
|
|
7
|
+
* the host PATH. The resulting {@link ProjectMap} is optionally written
|
|
8
|
+
* to `.claude-crap/projects.json` via {@link persistProjectMap} so
|
|
9
|
+
* subsequent boot cycles can skip the discovery work by calling
|
|
10
|
+
* {@link loadProjectMap} first.
|
|
11
|
+
*
|
|
12
|
+
* Detection priority per sub-directory (first match wins):
|
|
13
|
+
* pubspec.yaml → dart
|
|
14
|
+
* tsconfig.json + package.json → typescript
|
|
15
|
+
* package.json (no tsconfig) → javascript
|
|
16
|
+
* pyproject.toml / setup.py / requirements.txt → python
|
|
17
|
+
* pom.xml / build.gradle* → java
|
|
18
|
+
* *.csproj / *.sln / Directory.Build.props → csharp
|
|
19
|
+
* (none of the above) → unknown
|
|
20
|
+
*
|
|
21
|
+
* Scanner mapping:
|
|
22
|
+
* typescript / javascript → eslint
|
|
23
|
+
* python → bandit
|
|
24
|
+
* java / csharp → semgrep
|
|
25
|
+
* dart → dart_analyze
|
|
26
|
+
* unknown → null
|
|
27
|
+
*
|
|
28
|
+
* @module monorepo/project-map
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
32
|
+
import { promises as fs } from "node:fs";
|
|
33
|
+
import { join, basename, resolve } from "node:path";
|
|
34
|
+
import { execFile } from "node:child_process";
|
|
35
|
+
|
|
36
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detected language / platform for a workspace sub-project.
|
|
40
|
+
* Mirrors the set supported by the tree-sitter engine plus Dart.
|
|
41
|
+
*/
|
|
42
|
+
export type ProjectType =
|
|
43
|
+
| "typescript"
|
|
44
|
+
| "javascript"
|
|
45
|
+
| "python"
|
|
46
|
+
| "java"
|
|
47
|
+
| "csharp"
|
|
48
|
+
| "dart"
|
|
49
|
+
| "unknown";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A discovered sub-project within a monorepo workspace.
|
|
53
|
+
*/
|
|
54
|
+
export interface ProjectEntry {
|
|
55
|
+
/** Human-readable name — the directory's basename (e.g. "www", "mobile"). */
|
|
56
|
+
readonly name: string;
|
|
57
|
+
/** Relative path from the workspace root (e.g. "apps/www"). */
|
|
58
|
+
readonly path: string;
|
|
59
|
+
/** Detected project type based on marker files. */
|
|
60
|
+
readonly type: ProjectType;
|
|
61
|
+
/** Recommended scanner name, or null when the type is unknown. */
|
|
62
|
+
readonly scanner: string | null;
|
|
63
|
+
/** Whether the scanner binary is reachable on the system PATH. */
|
|
64
|
+
readonly scannerAvailable: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Complete snapshot of a workspace's sub-project layout.
|
|
69
|
+
*/
|
|
70
|
+
export interface ProjectMap {
|
|
71
|
+
/** ISO 8601 timestamp when this map was generated. */
|
|
72
|
+
readonly generatedAt: string;
|
|
73
|
+
/** Absolute path to the workspace root that was scanned. */
|
|
74
|
+
readonly workspaceRoot: string;
|
|
75
|
+
/** True when at least one sub-project was discovered. */
|
|
76
|
+
readonly isMonorepo: boolean;
|
|
77
|
+
/** Discovered sub-projects. Empty for single-project workspaces. */
|
|
78
|
+
readonly projects: ProjectEntry[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Internal constants ─────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* First-level directories that conventionally contain sub-projects in
|
|
85
|
+
* popular monorepo layouts (Nx, Turborepo, Rush, Lerna, custom).
|
|
86
|
+
*/
|
|
87
|
+
const MONOREPO_DIRS = ["apps", "packages", "libs", "modules", "services"] as const;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Scanner recommended for each project type. `null` means no scanner
|
|
91
|
+
* mapping is defined for the type (only "unknown" falls here).
|
|
92
|
+
*/
|
|
93
|
+
const SCANNER_FOR_TYPE: Record<ProjectType, string | null> = {
|
|
94
|
+
typescript: "eslint",
|
|
95
|
+
javascript: "eslint",
|
|
96
|
+
python: "bandit",
|
|
97
|
+
java: "semgrep",
|
|
98
|
+
csharp: "dotnet_format",
|
|
99
|
+
dart: "dart_analyze",
|
|
100
|
+
unknown: null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The binary name to probe for each scanner. Binary availability is
|
|
105
|
+
* checked with `which` via `execFile`, the same approach used in
|
|
106
|
+
* `scanner/detector.ts`.
|
|
107
|
+
*/
|
|
108
|
+
const BINARY_FOR_SCANNER: Record<string, string> = {
|
|
109
|
+
eslint: "eslint",
|
|
110
|
+
bandit: "bandit",
|
|
111
|
+
semgrep: "semgrep",
|
|
112
|
+
dart_analyze: "dart",
|
|
113
|
+
dotnet_format: "dotnet",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ── Binary probe ───────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve whether the given binary name is reachable on the system
|
|
120
|
+
* PATH. Uses `which` via `execFile` with a short timeout so boot
|
|
121
|
+
* latency stays bounded.
|
|
122
|
+
*
|
|
123
|
+
* @param binaryName The executable name to look up (e.g. "eslint").
|
|
124
|
+
* @returns True when `which` exits with code 0.
|
|
125
|
+
*/
|
|
126
|
+
function probeBinary(binaryName: string): Promise<boolean> {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
execFile("which", [binaryName], { timeout: 5_000 }, (err) => {
|
|
129
|
+
resolve(err === null);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Project type detection ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Detect the dominant project type of a single directory by inspecting
|
|
138
|
+
* well-known marker files. Checks are ordered so the most specific
|
|
139
|
+
* signal wins (pubspec.yaml before tsconfig.json, tsconfig.json before
|
|
140
|
+
* bare package.json, etc.).
|
|
141
|
+
*
|
|
142
|
+
* The function is synchronous because it only performs `existsSync` and
|
|
143
|
+
* one `readdirSync` call (for C# extension scanning), keeping the
|
|
144
|
+
* discovery loop fast and free of unnecessary Promise allocation.
|
|
145
|
+
*
|
|
146
|
+
* @param dir Absolute path to the directory to inspect.
|
|
147
|
+
* @returns The detected {@link ProjectType}.
|
|
148
|
+
*/
|
|
149
|
+
function detectProjectType(dir: string): ProjectType {
|
|
150
|
+
const has = (file: string): boolean => existsSync(join(dir, file));
|
|
151
|
+
|
|
152
|
+
// Dart / Flutter — check before JS because some Flutter projects also
|
|
153
|
+
// have a package.json for web sub-packages.
|
|
154
|
+
if (has("pubspec.yaml")) return "dart";
|
|
155
|
+
|
|
156
|
+
// JS / TS — tsconfig.json implies TypeScript superset.
|
|
157
|
+
if (has("package.json")) {
|
|
158
|
+
if (has("tsconfig.json")) return "typescript";
|
|
159
|
+
return "javascript";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Python
|
|
163
|
+
if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) {
|
|
164
|
+
return "python";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Java (Gradle and Maven)
|
|
168
|
+
if (has("pom.xml") || has("build.gradle") || has("build.gradle.kts")) {
|
|
169
|
+
return "java";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// C# — check the well-known single-file marker first, then scan for
|
|
173
|
+
// per-project extension files (.csproj / .sln) at this level only.
|
|
174
|
+
if (has("Directory.Build.props")) return "csharp";
|
|
175
|
+
try {
|
|
176
|
+
const entries = readdirSync(dir);
|
|
177
|
+
if (entries.some((e) => e.endsWith(".csproj") || e.endsWith(".sln"))) {
|
|
178
|
+
return "csharp";
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Permission error or symlink loop — treat as unknown.
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return "unknown";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Subdirectory collection ────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Normalise an npm workspaces value (which can be a plain string array
|
|
191
|
+
* or an object `{ packages: string[] }`) into a flat array of patterns.
|
|
192
|
+
*
|
|
193
|
+
* @param workspaces The raw value of `package.json#workspaces`.
|
|
194
|
+
* @returns Array of workspace glob patterns (may be empty).
|
|
195
|
+
*/
|
|
196
|
+
function extractWorkspacePatterns(workspaces: unknown): string[] {
|
|
197
|
+
if (Array.isArray(workspaces)) {
|
|
198
|
+
return workspaces.filter((v): v is string => typeof v === "string");
|
|
199
|
+
}
|
|
200
|
+
if (
|
|
201
|
+
workspaces !== null &&
|
|
202
|
+
typeof workspaces === "object" &&
|
|
203
|
+
"packages" in workspaces &&
|
|
204
|
+
Array.isArray((workspaces as { packages: unknown }).packages)
|
|
205
|
+
) {
|
|
206
|
+
return ((workspaces as { packages: unknown[] }).packages).filter(
|
|
207
|
+
(v): v is string => typeof v === "string",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Expand a single workspace glob pattern into matching absolute paths.
|
|
215
|
+
*
|
|
216
|
+
* Only supports the common `dir/*` form (one trailing `*`) and plain
|
|
217
|
+
* paths (no glob at all). Full glob engines are deliberately avoided to
|
|
218
|
+
* keep the module dependency-free.
|
|
219
|
+
*
|
|
220
|
+
* @param workspaceRoot Absolute workspace root.
|
|
221
|
+
* @param pattern A workspace pattern such as `"packages/*"` or `"apps/web"`.
|
|
222
|
+
* @returns Absolute paths of matching directories that exist on disk.
|
|
223
|
+
*/
|
|
224
|
+
function expandWorkspacePattern(
|
|
225
|
+
workspaceRoot: string,
|
|
226
|
+
pattern: string,
|
|
227
|
+
): string[] {
|
|
228
|
+
if (pattern.endsWith("/*")) {
|
|
229
|
+
// Glob: list one level of the parent directory.
|
|
230
|
+
const parentDir = join(workspaceRoot, pattern.slice(0, -2));
|
|
231
|
+
try {
|
|
232
|
+
const entries = readdirSync(parentDir, { withFileTypes: true });
|
|
233
|
+
return entries
|
|
234
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
235
|
+
.map((e) => join(parentDir, e.name));
|
|
236
|
+
} catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Plain path — verify it exists and is a directory.
|
|
242
|
+
const full = resolve(workspaceRoot, pattern);
|
|
243
|
+
try {
|
|
244
|
+
const entries = readdirSync(full, { withFileTypes: true });
|
|
245
|
+
// readdirSync succeeds only for directories; if we got here it exists.
|
|
246
|
+
void entries; // suppress unused-variable lint
|
|
247
|
+
return [full];
|
|
248
|
+
} catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Collect all candidate sub-project absolute paths for the given
|
|
255
|
+
* workspace root. Sources (deduplicated):
|
|
256
|
+
*
|
|
257
|
+
* 1. npm `workspaces` field in root `package.json` (both array and
|
|
258
|
+
* object-with-packages formats; glob patterns are expanded one
|
|
259
|
+
* level deep with `readdirSync`).
|
|
260
|
+
* 2. One-level-deep subdirectories of conventional monorepo folders
|
|
261
|
+
* (`apps/`, `packages/`, `libs/`, `modules/`, `services/`).
|
|
262
|
+
*
|
|
263
|
+
* Hidden directories (names starting with `.`) are always skipped.
|
|
264
|
+
*
|
|
265
|
+
* @param workspaceRoot Absolute path to the workspace root.
|
|
266
|
+
* @returns De-duplicated set of absolute sub-project paths.
|
|
267
|
+
*/
|
|
268
|
+
function collectSubdirectories(
|
|
269
|
+
workspaceRoot: string,
|
|
270
|
+
extraDirs?: ReadonlyArray<string>,
|
|
271
|
+
): Set<string> {
|
|
272
|
+
const subdirs = new Set<string>();
|
|
273
|
+
|
|
274
|
+
// 1. npm workspaces
|
|
275
|
+
const pkgPath = join(workspaceRoot, "package.json");
|
|
276
|
+
if (existsSync(pkgPath)) {
|
|
277
|
+
try {
|
|
278
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
279
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
280
|
+
const patterns = extractWorkspacePatterns(pkg["workspaces"]);
|
|
281
|
+
for (const pattern of patterns) {
|
|
282
|
+
for (const absPath of expandWorkspacePattern(workspaceRoot, pattern)) {
|
|
283
|
+
subdirs.add(absPath);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
// Malformed JSON or read error — skip npm workspaces source.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 2. User-configured projectDirs from .claude-crap.json (highest priority).
|
|
292
|
+
// These can be parent directories scanned one level deep (e.g. "apps")
|
|
293
|
+
// or direct project paths (e.g. "tools/cli").
|
|
294
|
+
if (extraDirs && extraDirs.length > 0) {
|
|
295
|
+
for (const dir of extraDirs) {
|
|
296
|
+
const absDir = resolve(workspaceRoot, dir);
|
|
297
|
+
if (!existsSync(absDir)) continue;
|
|
298
|
+
|
|
299
|
+
// If the directory itself has a project marker, treat it as a project.
|
|
300
|
+
const hasMarker = PROJECT_MARKERS.some((m) => existsSync(join(absDir, m)));
|
|
301
|
+
if (hasMarker) {
|
|
302
|
+
subdirs.add(absDir);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Otherwise scan one level deep (it's a parent directory like "apps").
|
|
307
|
+
try {
|
|
308
|
+
const entries = readdirSync(absDir, { withFileTypes: true });
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
311
|
+
subdirs.add(join(absDir, entry.name));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
// Not readable — skip.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 3. Conventional monorepo directories scanned one level deep.
|
|
321
|
+
// Skipped for directories already covered by user config.
|
|
322
|
+
const configuredDirNames = new Set(extraDirs?.map((d) => d.split("/")[0]) ?? []);
|
|
323
|
+
for (const dir of MONOREPO_DIRS) {
|
|
324
|
+
if (configuredDirNames.has(dir)) continue; // User config takes precedence
|
|
325
|
+
const parentDir = join(workspaceRoot, dir);
|
|
326
|
+
try {
|
|
327
|
+
const entries = readdirSync(parentDir, { withFileTypes: true });
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
330
|
+
subdirs.add(join(parentDir, entry.name));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Directory absent — skip.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return subdirs;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Files that indicate a directory is a project root. */
|
|
342
|
+
const PROJECT_MARKERS = [
|
|
343
|
+
"package.json", "pubspec.yaml", "pyproject.toml", "setup.py",
|
|
344
|
+
"pom.xml", "build.gradle", "build.gradle.kts", "Directory.Build.props",
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Discover all sub-projects in the given workspace and return a fully
|
|
351
|
+
* populated {@link ProjectMap}.
|
|
352
|
+
*
|
|
353
|
+
* The function:
|
|
354
|
+
* 1. Reads `package.json#workspaces` (supports both array and object
|
|
355
|
+
* `{ packages: [...] }` formats) and resolves glob patterns.
|
|
356
|
+
* 2. Also scans `apps/`, `packages/`, `libs/`, `modules/`, and
|
|
357
|
+
* `services/` one level deep (mirrors `detectMonorepoScanners`).
|
|
358
|
+
* 3. De-duplicates the collected paths.
|
|
359
|
+
* 4. Detects the project type for each subdirectory.
|
|
360
|
+
* 5. Maps project type to a recommended scanner.
|
|
361
|
+
* 6. Probes scanner binary availability via `which`.
|
|
362
|
+
*
|
|
363
|
+
* When no sub-projects are found (single-project workspace) `projects`
|
|
364
|
+
* is an empty array and `isMonorepo` is false.
|
|
365
|
+
*
|
|
366
|
+
* @param workspaceRoot Absolute path to the workspace root.
|
|
367
|
+
* @returns The generated {@link ProjectMap}.
|
|
368
|
+
*/
|
|
369
|
+
export async function discoverProjectMap(
|
|
370
|
+
workspaceRoot: string,
|
|
371
|
+
options?: { projectDirs?: ReadonlyArray<string> },
|
|
372
|
+
): Promise<ProjectMap> {
|
|
373
|
+
const subdirs = collectSubdirectories(workspaceRoot, options?.projectDirs);
|
|
374
|
+
|
|
375
|
+
// Cache binary probe results so each unique scanner is only probed once.
|
|
376
|
+
const binaryCache = new Map<string, Promise<boolean>>();
|
|
377
|
+
|
|
378
|
+
const probeScanner = (scanner: string): Promise<boolean> => {
|
|
379
|
+
const binaryName = BINARY_FOR_SCANNER[scanner];
|
|
380
|
+
if (binaryName === undefined) return Promise.resolve(false);
|
|
381
|
+
|
|
382
|
+
const cached = binaryCache.get(scanner);
|
|
383
|
+
if (cached !== undefined) return cached;
|
|
384
|
+
|
|
385
|
+
const probe = probeBinary(binaryName);
|
|
386
|
+
binaryCache.set(scanner, probe);
|
|
387
|
+
return probe;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const projectEntries = await Promise.all(
|
|
391
|
+
[...subdirs].map(async (absPath): Promise<ProjectEntry> => {
|
|
392
|
+
const relPath = absPath.replace(workspaceRoot + "/", "");
|
|
393
|
+
const type = detectProjectType(absPath);
|
|
394
|
+
const scanner = SCANNER_FOR_TYPE[type];
|
|
395
|
+
const scannerAvailable =
|
|
396
|
+
scanner !== null ? await probeScanner(scanner) : false;
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
name: basename(absPath),
|
|
400
|
+
path: relPath,
|
|
401
|
+
type,
|
|
402
|
+
scanner,
|
|
403
|
+
scannerAvailable,
|
|
404
|
+
};
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Sort deterministically by relative path so the output is stable
|
|
409
|
+
// across re-runs regardless of readdirSync ordering.
|
|
410
|
+
projectEntries.sort((a, b) => a.path.localeCompare(b.path));
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
generatedAt: new Date().toISOString(),
|
|
414
|
+
workspaceRoot,
|
|
415
|
+
isMonorepo: projectEntries.length > 0,
|
|
416
|
+
projects: projectEntries,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Write the project map to `.claude-crap/projects.json` under the given
|
|
422
|
+
* workspace root. The `.claude-crap/` directory is created if it does
|
|
423
|
+
* not already exist.
|
|
424
|
+
*
|
|
425
|
+
* The file is written atomically via `fs.writeFile` (Node's default
|
|
426
|
+
* behaviour on POSIX is to truncate-and-rewrite, which is safe for the
|
|
427
|
+
* sizes expected here).
|
|
428
|
+
*
|
|
429
|
+
* @param map The {@link ProjectMap} to serialise.
|
|
430
|
+
* @param workspaceRoot Absolute path to the workspace root.
|
|
431
|
+
*/
|
|
432
|
+
export async function persistProjectMap(
|
|
433
|
+
map: ProjectMap,
|
|
434
|
+
workspaceRoot: string,
|
|
435
|
+
): Promise<void> {
|
|
436
|
+
const dir = join(workspaceRoot, ".claude-crap");
|
|
437
|
+
await fs.mkdir(dir, { recursive: true });
|
|
438
|
+
const filePath = join(dir, "projects.json");
|
|
439
|
+
await fs.writeFile(filePath, JSON.stringify(map, null, 2) + "\n", "utf-8");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Read a previously persisted {@link ProjectMap} from
|
|
444
|
+
* `.claude-crap/projects.json`. Returns `null` when the file is absent
|
|
445
|
+
* or cannot be parsed, so callers can fall back to
|
|
446
|
+
* {@link discoverProjectMap} without special-casing errors.
|
|
447
|
+
*
|
|
448
|
+
* This function is intentionally synchronous so it can be called during
|
|
449
|
+
* plugin boot before the async event loop is fully initialised.
|
|
450
|
+
*
|
|
451
|
+
* @param workspaceRoot Absolute path to the workspace root.
|
|
452
|
+
* @returns The cached {@link ProjectMap}, or `null`.
|
|
453
|
+
*/
|
|
454
|
+
export function loadProjectMap(workspaceRoot: string): ProjectMap | null {
|
|
455
|
+
const filePath = join(workspaceRoot, ".claude-crap", "projects.json");
|
|
456
|
+
if (!existsSync(filePath)) return null;
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
460
|
+
const parsed = JSON.parse(raw) as ProjectMap;
|
|
461
|
+
|
|
462
|
+
// Minimal structural validation — guard against truncated writes.
|
|
463
|
+
if (
|
|
464
|
+
typeof parsed.generatedAt !== "string" ||
|
|
465
|
+
typeof parsed.workspaceRoot !== "string" ||
|
|
466
|
+
typeof parsed.isMonorepo !== "boolean" ||
|
|
467
|
+
!Array.isArray(parsed.projects)
|
|
468
|
+
) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return parsed;
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
package/src/scanner/bootstrap.ts
CHANGED
|
@@ -270,13 +270,19 @@ function getRecommendation(projectType: ProjectType): ScannerRecommendation {
|
|
|
270
270
|
"pip install bandit (or: pipx install bandit, poetry add --group dev bandit)",
|
|
271
271
|
};
|
|
272
272
|
case "java":
|
|
273
|
-
case "csharp":
|
|
274
273
|
return {
|
|
275
274
|
scanner: "semgrep",
|
|
276
275
|
canAutoInstall: false,
|
|
277
276
|
installInstructions:
|
|
278
277
|
"brew install semgrep (or: pip install semgrep, pipx install semgrep)",
|
|
279
278
|
};
|
|
279
|
+
case "csharp":
|
|
280
|
+
return {
|
|
281
|
+
scanner: "dotnet_format",
|
|
282
|
+
canAutoInstall: false,
|
|
283
|
+
installInstructions:
|
|
284
|
+
"Install the .NET SDK: https://dotnet.microsoft.com/download",
|
|
285
|
+
};
|
|
280
286
|
case "dart":
|
|
281
287
|
return {
|
|
282
288
|
scanner: "dart_analyze",
|
package/src/scanner/detector.ts
CHANGED
|
@@ -108,6 +108,11 @@ const SCANNER_SIGNALS: Record<KnownScanner, ScannerSignals> = {
|
|
|
108
108
|
packageJsonKeys: [],
|
|
109
109
|
binaryNames: ["dart"],
|
|
110
110
|
},
|
|
111
|
+
dotnet_format: {
|
|
112
|
+
configFiles: [],
|
|
113
|
+
packageJsonKeys: [],
|
|
114
|
+
binaryNames: ["dotnet"],
|
|
115
|
+
},
|
|
111
116
|
};
|
|
112
117
|
|
|
113
118
|
// ── Probes ──────────────────────────────────────────────────────────
|
|
@@ -183,7 +188,7 @@ function probeBinary(binaryName: string): Promise<boolean> {
|
|
|
183
188
|
export async function detectScanners(
|
|
184
189
|
workspaceRoot: string,
|
|
185
190
|
): Promise<ScannerDetection[]> {
|
|
186
|
-
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
|
|
191
|
+
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
|
|
187
192
|
|
|
188
193
|
const results = await Promise.all(
|
|
189
194
|
scanners.map(async (scanner): Promise<ScannerDetection> => {
|
|
@@ -297,7 +302,7 @@ export async function detectMonorepoScanners(
|
|
|
297
302
|
|
|
298
303
|
// 3. Probe each subdirectory for scanner config files
|
|
299
304
|
const detections: ScannerDetection[] = [];
|
|
300
|
-
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
|
|
305
|
+
const scanners: KnownScanner[] = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
|
|
301
306
|
|
|
302
307
|
for (const subdir of subdirs) {
|
|
303
308
|
for (const scanner of scanners) {
|
package/src/scanner/runner.ts
CHANGED
|
@@ -98,6 +98,19 @@ function getScannerCommand(
|
|
|
98
98
|
timeoutMs: 120_000,
|
|
99
99
|
nonZeroIsNormal: true, // exits 3 when findings exist
|
|
100
100
|
};
|
|
101
|
+
case "dotnet_format":
|
|
102
|
+
return {
|
|
103
|
+
command: "dotnet",
|
|
104
|
+
args: [
|
|
105
|
+
"format",
|
|
106
|
+
"--verify-no-changes",
|
|
107
|
+
"--report",
|
|
108
|
+
join(workspaceRoot, ".claude-crap", "dotnet-report.json"),
|
|
109
|
+
],
|
|
110
|
+
timeoutMs: 120_000,
|
|
111
|
+
nonZeroIsNormal: true,
|
|
112
|
+
outputFile: join(workspaceRoot, ".claude-crap", "dotnet-report.json"),
|
|
113
|
+
};
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
|
|
@@ -134,11 +134,27 @@ export const scoreProjectSchema = {
|
|
|
134
134
|
description:
|
|
135
135
|
"Output format. `markdown` returns only the chat summary, `json` returns only the structured snapshot, `both` (default) returns both as separate content blocks.",
|
|
136
136
|
},
|
|
137
|
+
scope: {
|
|
138
|
+
type: "string",
|
|
139
|
+
description: "Optional project name from the project map. When provided, the score is computed only for files within that project's subtree. Omit to score the entire workspace.",
|
|
140
|
+
},
|
|
137
141
|
},
|
|
138
142
|
required: [],
|
|
139
143
|
additionalProperties: false,
|
|
140
144
|
} as const;
|
|
141
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Schema for the `list_projects` tool. Returns the discovered sub-projects
|
|
148
|
+
* in the workspace, or an empty list for single-project workspaces.
|
|
149
|
+
*/
|
|
150
|
+
export const listProjectsSchema = {
|
|
151
|
+
type: "object",
|
|
152
|
+
description: "List all discovered sub-projects in the workspace. In a monorepo, returns each sub-project with its type, path, and recommended scanner. In a single-project workspace, returns an empty list.",
|
|
153
|
+
properties: {},
|
|
154
|
+
required: [],
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
} as const;
|
|
157
|
+
|
|
142
158
|
/**
|
|
143
159
|
* Schema for the `require_test_harness` tool. Checks whether a production
|
|
144
160
|
* source file has an accompanying test file in any of the conventional
|
|
@@ -181,7 +197,7 @@ export const ingestScannerOutputSchema = {
|
|
|
181
197
|
properties: {
|
|
182
198
|
scanner: {
|
|
183
199
|
type: "string",
|
|
184
|
-
enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze"],
|
|
200
|
+
enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze", "dotnet_format"],
|
|
185
201
|
description: "Identifier of the producing scanner.",
|
|
186
202
|
},
|
|
187
203
|
rawOutput: {
|
|
@@ -95,6 +95,6 @@ describe("adaptScannerOutput", () => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
it("KNOWN_SCANNERS is frozen and contains all supported names", () => {
|
|
98
|
-
assert.deepEqual([...KNOWN_SCANNERS].sort(), ["bandit", "dart_analyze", "eslint", "semgrep", "stryker"]);
|
|
98
|
+
assert.deepEqual([...KNOWN_SCANNERS].sort(), ["bandit", "dart_analyze", "dotnet_format", "eslint", "semgrep", "stryker"]);
|
|
99
99
|
});
|
|
100
100
|
});
|
|
@@ -34,7 +34,7 @@ describe("autoScan", () => {
|
|
|
34
34
|
outputDir: join(dir, ".claude-crap/reports"),
|
|
35
35
|
});
|
|
36
36
|
const result = await autoScan(dir, store, logger);
|
|
37
|
-
assert.equal(result.detected.length,
|
|
37
|
+
assert.equal(result.detected.length, 6);
|
|
38
38
|
assert.ok(result.totalDurationMs >= 0);
|
|
39
39
|
// No scanners available means no results
|
|
40
40
|
// (unless the host has scanner binaries installed)
|
|
@@ -129,7 +129,7 @@ describe("autoScan", () => {
|
|
|
129
129
|
const result = await autoScan(dir, store, logger);
|
|
130
130
|
|
|
131
131
|
const scannerNames = result.detected.map((d) => d.scanner).sort();
|
|
132
|
-
assert.deepEqual(scannerNames, ["bandit", "dart_analyze", "eslint", "semgrep", "stryker"]);
|
|
132
|
+
assert.deepEqual(scannerNames, ["bandit", "dart_analyze", "dotnet_format", "eslint", "semgrep", "stryker"]);
|
|
133
133
|
} finally {
|
|
134
134
|
rmSync(dir, { recursive: true, force: true });
|
|
135
135
|
}
|