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.
Files changed (64) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +69 -27
  3. package/dist/adapters/common.d.ts +1 -1
  4. package/dist/adapters/common.d.ts.map +1 -1
  5. package/dist/adapters/common.js +1 -1
  6. package/dist/adapters/common.js.map +1 -1
  7. package/dist/adapters/dotnet-format.d.ts +35 -0
  8. package/dist/adapters/dotnet-format.d.ts.map +1 -0
  9. package/dist/adapters/dotnet-format.js +96 -0
  10. package/dist/adapters/dotnet-format.js.map +1 -0
  11. package/dist/adapters/index.d.ts +1 -0
  12. package/dist/adapters/index.d.ts.map +1 -1
  13. package/dist/adapters/index.js +4 -0
  14. package/dist/adapters/index.js.map +1 -1
  15. package/dist/crap-config.d.ts +2 -0
  16. package/dist/crap-config.d.ts.map +1 -1
  17. package/dist/crap-config.js +19 -4
  18. package/dist/crap-config.js.map +1 -1
  19. package/dist/dashboard/server.js +1 -1
  20. package/dist/index.js +74 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/monorepo/project-map.d.ts +112 -0
  23. package/dist/monorepo/project-map.d.ts.map +1 -0
  24. package/dist/monorepo/project-map.js +384 -0
  25. package/dist/monorepo/project-map.js.map +1 -0
  26. package/dist/scanner/bootstrap.d.ts.map +1 -1
  27. package/dist/scanner/bootstrap.js +6 -1
  28. package/dist/scanner/bootstrap.js.map +1 -1
  29. package/dist/scanner/detector.d.ts.map +1 -1
  30. package/dist/scanner/detector.js +7 -2
  31. package/dist/scanner/detector.js.map +1 -1
  32. package/dist/scanner/runner.d.ts.map +1 -1
  33. package/dist/scanner/runner.js +13 -0
  34. package/dist/scanner/runner.js.map +1 -1
  35. package/dist/schemas/tool-schemas.d.ts +16 -1
  36. package/dist/schemas/tool-schemas.d.ts.map +1 -1
  37. package/dist/schemas/tool-schemas.js +16 -1
  38. package/dist/schemas/tool-schemas.js.map +1 -1
  39. package/package.json +1 -1
  40. package/plugin/.claude-plugin/plugin.json +1 -1
  41. package/plugin/CLAUDE.md +37 -0
  42. package/plugin/bundle/mcp-server.mjs +395 -29
  43. package/plugin/bundle/mcp-server.mjs.map +4 -4
  44. package/plugin/package-lock.json +2 -2
  45. package/plugin/package.json +1 -1
  46. package/src/adapters/common.ts +1 -1
  47. package/src/adapters/dotnet-format.ts +125 -0
  48. package/src/adapters/index.ts +4 -0
  49. package/src/crap-config.ts +27 -4
  50. package/src/dashboard/server.ts +1 -1
  51. package/src/index.ts +88 -5
  52. package/src/monorepo/project-map.ts +476 -0
  53. package/src/scanner/bootstrap.ts +7 -1
  54. package/src/scanner/detector.ts +7 -2
  55. package/src/scanner/runner.ts +13 -0
  56. package/src/schemas/tool-schemas.ts +17 -1
  57. package/src/tests/adapters/dispatch.test.ts +1 -1
  58. package/src/tests/auto-scan.test.ts +2 -2
  59. package/src/tests/boot-monorepo.test.ts +804 -0
  60. package/src/tests/boot-scanner-detection.test.ts +692 -0
  61. package/src/tests/boot-single-project.test.ts +780 -0
  62. package/src/tests/integration/mcp-server.integration.test.ts +2 -1
  63. package/src/tests/project-map.test.ts +302 -0
  64. package/src/tests/scanner-detector.test.ts +4 -4
@@ -2977,7 +2977,7 @@ var require_compile = __commonJS({
2977
2977
  const schOrFunc = root.refs[ref];
2978
2978
  if (schOrFunc)
2979
2979
  return schOrFunc;
2980
- let _sch = resolve7.call(this, root, ref);
2980
+ let _sch = resolve8.call(this, root, ref);
2981
2981
  if (_sch === void 0) {
2982
2982
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2983
2983
  const { schemaId } = this.opts;
@@ -3004,7 +3004,7 @@ var require_compile = __commonJS({
3004
3004
  function sameSchemaEnv(s1, s2) {
3005
3005
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3006
3006
  }
3007
- function resolve7(root, ref) {
3007
+ function resolve8(root, ref) {
3008
3008
  let sch;
3009
3009
  while (typeof (sch = this.refs[ref]) == "string")
3010
3010
  ref = sch;
@@ -3579,7 +3579,7 @@ var require_fast_uri = __commonJS({
3579
3579
  }
3580
3580
  return uri;
3581
3581
  }
3582
- function resolve7(baseURI, relativeURI, options) {
3582
+ function resolve8(baseURI, relativeURI, options) {
3583
3583
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3584
3584
  const resolved = resolveComponent(parse(baseURI, schemelessOptions), parse(relativeURI, schemelessOptions), schemelessOptions, true);
3585
3585
  schemelessOptions.skipEscape = true;
@@ -3806,7 +3806,7 @@ var require_fast_uri = __commonJS({
3806
3806
  var fastUri = {
3807
3807
  SCHEMES,
3808
3808
  normalize,
3809
- resolve: resolve7,
3809
+ resolve: resolve8,
3810
3810
  resolveComponent,
3811
3811
  equal,
3812
3812
  serialize,
@@ -6931,6 +6931,67 @@ function adaptDartAnalyzer(rawOutput) {
6931
6931
  };
6932
6932
  }
6933
6933
 
6934
+ // src/adapters/dotnet-format.ts
6935
+ function adaptDotnetFormat(rawOutput) {
6936
+ let parsed;
6937
+ if (typeof rawOutput === "string") {
6938
+ try {
6939
+ parsed = JSON.parse(rawOutput);
6940
+ } catch {
6941
+ throw new Error("[dotnet-format adapter] rawOutput is not valid JSON");
6942
+ }
6943
+ } else if (Array.isArray(rawOutput)) {
6944
+ parsed = rawOutput;
6945
+ } else {
6946
+ throw new Error(
6947
+ "[dotnet-format adapter] rawOutput must be a JSON string or an array of document entries"
6948
+ );
6949
+ }
6950
+ if (!Array.isArray(parsed)) {
6951
+ throw new Error("[dotnet-format adapter] parsed output must be an array");
6952
+ }
6953
+ const EFFORT_MINUTES = 5;
6954
+ const results = [];
6955
+ let findingCount = 0;
6956
+ let totalEffortMinutes = 0;
6957
+ for (const doc of parsed) {
6958
+ if (!Array.isArray(doc.FileChanges)) continue;
6959
+ for (const change of doc.FileChanges) {
6960
+ findingCount++;
6961
+ totalEffortMinutes += EFFORT_MINUTES;
6962
+ results.push({
6963
+ ruleId: change.DiagnosticId,
6964
+ level: "warning",
6965
+ message: {
6966
+ text: change.FormatDescription
6967
+ },
6968
+ locations: [
6969
+ {
6970
+ physicalLocation: {
6971
+ artifactLocation: {
6972
+ uri: doc.FilePath
6973
+ },
6974
+ region: {
6975
+ startLine: change.LineNumber,
6976
+ startColumn: change.CharNumber
6977
+ }
6978
+ }
6979
+ }
6980
+ ],
6981
+ properties: {
6982
+ effortMinutes: EFFORT_MINUTES
6983
+ }
6984
+ });
6985
+ }
6986
+ }
6987
+ return {
6988
+ document: wrapResultsInSarif("dotnet_format", "1.0.0", results),
6989
+ sourceTool: "dotnet_format",
6990
+ findingCount,
6991
+ totalEffortMinutes
6992
+ };
6993
+ }
6994
+
6934
6995
  // src/adapters/index.ts
6935
6996
  function adaptScannerOutput(scanner, rawOutput) {
6936
6997
  switch (scanner) {
@@ -6944,6 +7005,8 @@ function adaptScannerOutput(scanner, rawOutput) {
6944
7005
  return adaptStryker(rawOutput);
6945
7006
  case "dart_analyze":
6946
7007
  return adaptDartAnalyzer(rawOutput);
7008
+ case "dotnet_format":
7009
+ return adaptDotnetFormat(rawOutput);
6947
7010
  default: {
6948
7011
  const exhaustive = scanner;
6949
7012
  throw new Error(`[adapters] Unknown scanner: ${String(exhaustive)}`);
@@ -7698,7 +7761,7 @@ async function startDashboard(options) {
7698
7761
  root: publicRoot,
7699
7762
  prefix: "/"
7700
7763
  });
7701
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.8" }));
7764
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.4.0" }));
7702
7765
  fastify.get("/api/score", async () => {
7703
7766
  const stats = await workspaceStatsProvider();
7704
7767
  const score = await buildScore(config, sarifStore, stats, urlOf(fastify, config));
@@ -8373,6 +8436,7 @@ var CrapConfigError = class extends Error {
8373
8436
  function loadCrapConfig(options) {
8374
8437
  const fileResult = readFromFile(options.workspaceRoot);
8375
8438
  const exclude = fileResult?.exclude ?? [];
8439
+ const projectDirs = fileResult?.projectDirs ?? [];
8376
8440
  const envRaw = process.env["CLAUDE_CRAP_STRICTNESS"];
8377
8441
  if (typeof envRaw === "string" && envRaw.trim() !== "") {
8378
8442
  const normalized = envRaw.trim().toLowerCase();
@@ -8381,12 +8445,12 @@ function loadCrapConfig(options) {
8381
8445
  `[crap-config] CLAUDE_CRAP_STRICTNESS="${envRaw}" is not a valid strictness. Expected one of: ${STRICTNESS_VALUES.join(", ")}.`
8382
8446
  );
8383
8447
  }
8384
- return { strictness: normalized, strictnessSource: "env", exclude };
8448
+ return { strictness: normalized, strictnessSource: "env", exclude, projectDirs };
8385
8449
  }
8386
8450
  if (fileResult?.strictness) {
8387
- return { strictness: fileResult.strictness, strictnessSource: "file", exclude };
8451
+ return { strictness: fileResult.strictness, strictnessSource: "file", exclude, projectDirs };
8388
8452
  }
8389
- return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default", exclude };
8453
+ return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default", exclude, projectDirs };
8390
8454
  }
8391
8455
  function readFromFile(workspaceRoot) {
8392
8456
  const filePath = join5(workspaceRoot, ".claude-crap.json");
@@ -8447,7 +8511,24 @@ function readFromFile(workspaceRoot) {
8447
8511
  }
8448
8512
  exclude = raw2;
8449
8513
  }
8450
- return { strictness, exclude };
8514
+ let projectDirs = [];
8515
+ if ("projectDirs" in doc) {
8516
+ const raw2 = doc["projectDirs"];
8517
+ if (!Array.isArray(raw2)) {
8518
+ throw new CrapConfigError(
8519
+ `[crap-config] ${filePath}: 'projectDirs' must be an array of strings`
8520
+ );
8521
+ }
8522
+ for (const item of raw2) {
8523
+ if (typeof item !== "string") {
8524
+ throw new CrapConfigError(
8525
+ `[crap-config] ${filePath}: every entry in 'projectDirs' must be a string, got ${typeof item}`
8526
+ );
8527
+ }
8528
+ }
8529
+ projectDirs = raw2;
8530
+ }
8531
+ return { strictness, exclude, projectDirs };
8451
8532
  }
8452
8533
  function isStrictness(value) {
8453
8534
  return STRICTNESS_VALUES.includes(value);
@@ -8580,6 +8661,11 @@ var SCANNER_SIGNALS = {
8580
8661
  ],
8581
8662
  packageJsonKeys: [],
8582
8663
  binaryNames: ["dart"]
8664
+ },
8665
+ dotnet_format: {
8666
+ configFiles: [],
8667
+ packageJsonKeys: [],
8668
+ binaryNames: ["dotnet"]
8583
8669
  }
8584
8670
  };
8585
8671
  function probeConfigFiles(workspaceRoot, scanner) {
@@ -8610,14 +8696,14 @@ function probePackageJson(workspaceRoot, scanner) {
8610
8696
  }
8611
8697
  }
8612
8698
  function probeBinary(binaryName) {
8613
- return new Promise((resolve7) => {
8699
+ return new Promise((resolve8) => {
8614
8700
  execFile("which", [binaryName], { timeout: 5e3 }, (err) => {
8615
- resolve7(err === null);
8701
+ resolve8(err === null);
8616
8702
  });
8617
8703
  });
8618
8704
  }
8619
8705
  async function detectScanners(workspaceRoot) {
8620
- const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
8706
+ const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
8621
8707
  const results = await Promise.all(
8622
8708
  scanners.map(async (scanner) => {
8623
8709
  const configProbe = probeConfigFiles(workspaceRoot, scanner);
@@ -8689,7 +8775,7 @@ async function detectMonorepoScanners(workspaceRoot) {
8689
8775
  }
8690
8776
  if (subdirs.size === 0) return [];
8691
8777
  const detections = [];
8692
- const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
8778
+ const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
8693
8779
  for (const subdir of subdirs) {
8694
8780
  for (const scanner of scanners) {
8695
8781
  const configProbe = probeConfigFiles(subdir, scanner);
@@ -8754,13 +8840,26 @@ function getScannerCommand(scanner, workspaceRoot) {
8754
8840
  nonZeroIsNormal: true
8755
8841
  // exits 3 when findings exist
8756
8842
  };
8843
+ case "dotnet_format":
8844
+ return {
8845
+ command: "dotnet",
8846
+ args: [
8847
+ "format",
8848
+ "--verify-no-changes",
8849
+ "--report",
8850
+ join8(workspaceRoot, ".claude-crap", "dotnet-report.json")
8851
+ ],
8852
+ timeoutMs: 12e4,
8853
+ nonZeroIsNormal: true,
8854
+ outputFile: join8(workspaceRoot, ".claude-crap", "dotnet-report.json")
8855
+ };
8757
8856
  }
8758
8857
  }
8759
8858
  function runScanner(scanner, workspaceRoot, options) {
8760
8859
  const start = Date.now();
8761
8860
  const cwd = options?.workingDir ?? workspaceRoot;
8762
8861
  const cmd = getScannerCommand(scanner, cwd);
8763
- return new Promise((resolve7) => {
8862
+ return new Promise((resolve8) => {
8764
8863
  execFile2(
8765
8864
  cmd.command,
8766
8865
  cmd.args,
@@ -8779,7 +8878,7 @@ function runScanner(scanner, workspaceRoot, options) {
8779
8878
  if (cmd.outputFile && existsSync3(cmd.outputFile)) {
8780
8879
  try {
8781
8880
  const fileOutput = readFileSync4(cmd.outputFile, "utf-8");
8782
- resolve7({
8881
+ resolve8({
8783
8882
  scanner,
8784
8883
  success: true,
8785
8884
  rawOutput: fileOutput,
@@ -8789,7 +8888,7 @@ function runScanner(scanner, workspaceRoot, options) {
8789
8888
  } catch {
8790
8889
  }
8791
8890
  }
8792
- resolve7({
8891
+ resolve8({
8793
8892
  scanner,
8794
8893
  success: false,
8795
8894
  rawOutput: "",
@@ -8802,7 +8901,7 @@ function runScanner(scanner, workspaceRoot, options) {
8802
8901
  if (existsSync3(cmd.outputFile)) {
8803
8902
  try {
8804
8903
  const fileOutput = readFileSync4(cmd.outputFile, "utf-8");
8805
- resolve7({
8904
+ resolve8({
8806
8905
  scanner,
8807
8906
  success: true,
8808
8907
  rawOutput: fileOutput,
@@ -8810,7 +8909,7 @@ function runScanner(scanner, workspaceRoot, options) {
8810
8909
  });
8811
8910
  return;
8812
8911
  } catch (readErr) {
8813
- resolve7({
8912
+ resolve8({
8814
8913
  scanner,
8815
8914
  success: false,
8816
8915
  rawOutput: "",
@@ -8820,7 +8919,7 @@ function runScanner(scanner, workspaceRoot, options) {
8820
8919
  return;
8821
8920
  }
8822
8921
  }
8823
- resolve7({
8922
+ resolve8({
8824
8923
  scanner,
8825
8924
  success: false,
8826
8925
  rawOutput: "",
@@ -8831,7 +8930,7 @@ function runScanner(scanner, workspaceRoot, options) {
8831
8930
  }
8832
8931
  const output = stdout.trim();
8833
8932
  if (!output) {
8834
- resolve7({
8933
+ resolve8({
8835
8934
  scanner,
8836
8935
  success: true,
8837
8936
  rawOutput: "[]",
@@ -8840,7 +8939,7 @@ function runScanner(scanner, workspaceRoot, options) {
8840
8939
  });
8841
8940
  return;
8842
8941
  }
8843
- resolve7({
8942
+ resolve8({
8844
8943
  scanner,
8845
8944
  success: true,
8846
8945
  rawOutput: output,
@@ -8917,7 +9016,7 @@ export default [
8917
9016
  `;
8918
9017
  }
8919
9018
  function npmInstall(workspaceRoot, packages) {
8920
- return new Promise((resolve7) => {
9019
+ return new Promise((resolve8) => {
8921
9020
  execFile3(
8922
9021
  "npm",
8923
9022
  ["install", "--save-dev", ...packages],
@@ -8928,14 +9027,14 @@ function npmInstall(workspaceRoot, packages) {
8928
9027
  },
8929
9028
  (err, stdout, stderr) => {
8930
9029
  if (err) {
8931
- resolve7({
9030
+ resolve8({
8932
9031
  action: `npm install --save-dev ${packages.join(" ")}`,
8933
9032
  success: false,
8934
9033
  detail: stderr || err.message
8935
9034
  });
8936
9035
  return;
8937
9036
  }
8938
- resolve7({
9037
+ resolve8({
8939
9038
  action: `npm install --save-dev ${packages.join(" ")}`,
8940
9039
  success: true,
8941
9040
  detail: `installed ${packages.join(", ")}`
@@ -8984,12 +9083,17 @@ function getRecommendation(projectType) {
8984
9083
  installInstructions: "pip install bandit (or: pipx install bandit, poetry add --group dev bandit)"
8985
9084
  };
8986
9085
  case "java":
8987
- case "csharp":
8988
9086
  return {
8989
9087
  scanner: "semgrep",
8990
9088
  canAutoInstall: false,
8991
9089
  installInstructions: "brew install semgrep (or: pip install semgrep, pipx install semgrep)"
8992
9090
  };
9091
+ case "csharp":
9092
+ return {
9093
+ scanner: "dotnet_format",
9094
+ canAutoInstall: false,
9095
+ installInstructions: "Install the .NET SDK: https://dotnet.microsoft.com/download"
9096
+ };
8993
9097
  case "dart":
8994
9098
  return {
8995
9099
  scanner: "dart_analyze",
@@ -9437,6 +9541,192 @@ async function autoScan(workspaceRoot, sarifStore, logger2, options) {
9437
9541
  };
9438
9542
  }
9439
9543
 
9544
+ // src/monorepo/project-map.ts
9545
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync3 } from "node:fs";
9546
+ import { promises as fs8 } from "node:fs";
9547
+ import { join as join12, basename as basename2, resolve as resolve7 } from "node:path";
9548
+ import { execFile as execFile4 } from "node:child_process";
9549
+ var MONOREPO_DIRS2 = ["apps", "packages", "libs", "modules", "services"];
9550
+ var SCANNER_FOR_TYPE = {
9551
+ typescript: "eslint",
9552
+ javascript: "eslint",
9553
+ python: "bandit",
9554
+ java: "semgrep",
9555
+ csharp: "dotnet_format",
9556
+ dart: "dart_analyze",
9557
+ unknown: null
9558
+ };
9559
+ var BINARY_FOR_SCANNER = {
9560
+ eslint: "eslint",
9561
+ bandit: "bandit",
9562
+ semgrep: "semgrep",
9563
+ dart_analyze: "dart",
9564
+ dotnet_format: "dotnet"
9565
+ };
9566
+ function probeBinary2(binaryName) {
9567
+ return new Promise((resolve8) => {
9568
+ execFile4("which", [binaryName], { timeout: 5e3 }, (err) => {
9569
+ resolve8(err === null);
9570
+ });
9571
+ });
9572
+ }
9573
+ function detectProjectType2(dir) {
9574
+ const has = (file) => existsSync6(join12(dir, file));
9575
+ if (has("pubspec.yaml")) return "dart";
9576
+ if (has("package.json")) {
9577
+ if (has("tsconfig.json")) return "typescript";
9578
+ return "javascript";
9579
+ }
9580
+ if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) {
9581
+ return "python";
9582
+ }
9583
+ if (has("pom.xml") || has("build.gradle") || has("build.gradle.kts")) {
9584
+ return "java";
9585
+ }
9586
+ if (has("Directory.Build.props")) return "csharp";
9587
+ try {
9588
+ const entries = readdirSync3(dir);
9589
+ if (entries.some((e) => e.endsWith(".csproj") || e.endsWith(".sln"))) {
9590
+ return "csharp";
9591
+ }
9592
+ } catch {
9593
+ }
9594
+ return "unknown";
9595
+ }
9596
+ function extractWorkspacePatterns(workspaces) {
9597
+ if (Array.isArray(workspaces)) {
9598
+ return workspaces.filter((v) => typeof v === "string");
9599
+ }
9600
+ if (workspaces !== null && typeof workspaces === "object" && "packages" in workspaces && Array.isArray(workspaces.packages)) {
9601
+ return workspaces.packages.filter(
9602
+ (v) => typeof v === "string"
9603
+ );
9604
+ }
9605
+ return [];
9606
+ }
9607
+ function expandWorkspacePattern(workspaceRoot, pattern) {
9608
+ if (pattern.endsWith("/*")) {
9609
+ const parentDir = join12(workspaceRoot, pattern.slice(0, -2));
9610
+ try {
9611
+ const entries = readdirSync3(parentDir, { withFileTypes: true });
9612
+ return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => join12(parentDir, e.name));
9613
+ } catch {
9614
+ return [];
9615
+ }
9616
+ }
9617
+ const full = resolve7(workspaceRoot, pattern);
9618
+ try {
9619
+ const entries = readdirSync3(full, { withFileTypes: true });
9620
+ void entries;
9621
+ return [full];
9622
+ } catch {
9623
+ return [];
9624
+ }
9625
+ }
9626
+ function collectSubdirectories(workspaceRoot, extraDirs) {
9627
+ const subdirs = /* @__PURE__ */ new Set();
9628
+ const pkgPath = join12(workspaceRoot, "package.json");
9629
+ if (existsSync6(pkgPath)) {
9630
+ try {
9631
+ const raw = readFileSync5(pkgPath, "utf-8");
9632
+ const pkg = JSON.parse(raw);
9633
+ const patterns = extractWorkspacePatterns(pkg["workspaces"]);
9634
+ for (const pattern of patterns) {
9635
+ for (const absPath of expandWorkspacePattern(workspaceRoot, pattern)) {
9636
+ subdirs.add(absPath);
9637
+ }
9638
+ }
9639
+ } catch {
9640
+ }
9641
+ }
9642
+ if (extraDirs && extraDirs.length > 0) {
9643
+ for (const dir of extraDirs) {
9644
+ const absDir = resolve7(workspaceRoot, dir);
9645
+ if (!existsSync6(absDir)) continue;
9646
+ const hasMarker = PROJECT_MARKERS.some((m) => existsSync6(join12(absDir, m)));
9647
+ if (hasMarker) {
9648
+ subdirs.add(absDir);
9649
+ continue;
9650
+ }
9651
+ try {
9652
+ const entries = readdirSync3(absDir, { withFileTypes: true });
9653
+ for (const entry of entries) {
9654
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
9655
+ subdirs.add(join12(absDir, entry.name));
9656
+ }
9657
+ }
9658
+ } catch {
9659
+ }
9660
+ }
9661
+ }
9662
+ const configuredDirNames = new Set(extraDirs?.map((d) => d.split("/")[0]) ?? []);
9663
+ for (const dir of MONOREPO_DIRS2) {
9664
+ if (configuredDirNames.has(dir)) continue;
9665
+ const parentDir = join12(workspaceRoot, dir);
9666
+ try {
9667
+ const entries = readdirSync3(parentDir, { withFileTypes: true });
9668
+ for (const entry of entries) {
9669
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
9670
+ subdirs.add(join12(parentDir, entry.name));
9671
+ }
9672
+ }
9673
+ } catch {
9674
+ }
9675
+ }
9676
+ return subdirs;
9677
+ }
9678
+ var PROJECT_MARKERS = [
9679
+ "package.json",
9680
+ "pubspec.yaml",
9681
+ "pyproject.toml",
9682
+ "setup.py",
9683
+ "pom.xml",
9684
+ "build.gradle",
9685
+ "build.gradle.kts",
9686
+ "Directory.Build.props"
9687
+ ];
9688
+ async function discoverProjectMap(workspaceRoot, options) {
9689
+ const subdirs = collectSubdirectories(workspaceRoot, options?.projectDirs);
9690
+ const binaryCache = /* @__PURE__ */ new Map();
9691
+ const probeScanner = (scanner) => {
9692
+ const binaryName = BINARY_FOR_SCANNER[scanner];
9693
+ if (binaryName === void 0) return Promise.resolve(false);
9694
+ const cached = binaryCache.get(scanner);
9695
+ if (cached !== void 0) return cached;
9696
+ const probe = probeBinary2(binaryName);
9697
+ binaryCache.set(scanner, probe);
9698
+ return probe;
9699
+ };
9700
+ const projectEntries = await Promise.all(
9701
+ [...subdirs].map(async (absPath) => {
9702
+ const relPath = absPath.replace(workspaceRoot + "/", "");
9703
+ const type = detectProjectType2(absPath);
9704
+ const scanner = SCANNER_FOR_TYPE[type];
9705
+ const scannerAvailable = scanner !== null ? await probeScanner(scanner) : false;
9706
+ return {
9707
+ name: basename2(absPath),
9708
+ path: relPath,
9709
+ type,
9710
+ scanner,
9711
+ scannerAvailable
9712
+ };
9713
+ })
9714
+ );
9715
+ projectEntries.sort((a, b) => a.path.localeCompare(b.path));
9716
+ return {
9717
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9718
+ workspaceRoot,
9719
+ isMonorepo: projectEntries.length > 0,
9720
+ projects: projectEntries
9721
+ };
9722
+ }
9723
+ async function persistProjectMap(map, workspaceRoot) {
9724
+ const dir = join12(workspaceRoot, ".claude-crap");
9725
+ await fs8.mkdir(dir, { recursive: true });
9726
+ const filePath = join12(dir, "projects.json");
9727
+ await fs8.writeFile(filePath, JSON.stringify(map, null, 2) + "\n", "utf-8");
9728
+ }
9729
+
9440
9730
  // src/schemas/tool-schemas.ts
9441
9731
  var computeCrapSchema = {
9442
9732
  type: "object",
@@ -9527,11 +9817,22 @@ var scoreProjectSchema = {
9527
9817
  type: "string",
9528
9818
  enum: ["markdown", "json", "both"],
9529
9819
  description: "Output format. `markdown` returns only the chat summary, `json` returns only the structured snapshot, `both` (default) returns both as separate content blocks."
9820
+ },
9821
+ scope: {
9822
+ type: "string",
9823
+ 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."
9530
9824
  }
9531
9825
  },
9532
9826
  required: [],
9533
9827
  additionalProperties: false
9534
9828
  };
9829
+ var listProjectsSchema = {
9830
+ type: "object",
9831
+ 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.",
9832
+ properties: {},
9833
+ required: [],
9834
+ additionalProperties: false
9835
+ };
9535
9836
  var requireTestHarnessSchema = {
9536
9837
  type: "object",
9537
9838
  description: "Check whether a production source file has a matching test file. Returns the first existing test path, or the full list of paths the resolver probed when none exists. Use this BEFORE writing any functional code \u2014 the CLAUDE.md Golden Rule requires a test harness to exist first.",
@@ -9553,7 +9854,7 @@ var ingestScannerOutputSchema = {
9553
9854
  properties: {
9554
9855
  scanner: {
9555
9856
  type: "string",
9556
- enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze"],
9857
+ enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze", "dotnet_format"],
9557
9858
  description: "Identifier of the producing scanner."
9558
9859
  },
9559
9860
  rawOutput: {
@@ -9614,12 +9915,17 @@ async function main() {
9614
9915
  "claude-crap MCP server starting"
9615
9916
  );
9616
9917
  let userExclusions = [];
9918
+ let userProjectDirs = [];
9617
9919
  try {
9618
9920
  const crapConfig = loadCrapConfig({ workspaceRoot: config.pluginRoot });
9619
9921
  userExclusions = crapConfig.exclude;
9922
+ userProjectDirs = crapConfig.projectDirs;
9620
9923
  if (userExclusions.length > 0) {
9621
9924
  logger.info({ exclude: userExclusions }, "user exclusions loaded from .claude-crap.json");
9622
9925
  }
9926
+ if (userProjectDirs.length > 0) {
9927
+ logger.info({ projectDirs: userProjectDirs }, "user projectDirs loaded from .claude-crap.json");
9928
+ }
9623
9929
  } catch {
9624
9930
  }
9625
9931
  const astEngine = new TreeSitterEngine();
@@ -9632,6 +9938,32 @@ async function main() {
9632
9938
  { findings: sarifStore.size(), path: sarifStore.consolidatedReportPath },
9633
9939
  "SARIF store ready"
9634
9940
  );
9941
+ let projectMap = null;
9942
+ try {
9943
+ projectMap = await discoverProjectMap(config.pluginRoot, { projectDirs: userProjectDirs });
9944
+ if (projectMap.isMonorepo) {
9945
+ logger.info(
9946
+ { projects: projectMap.projects.map((p) => `${p.name}(${p.type})`), count: projectMap.projects.length },
9947
+ "monorepo project map discovered"
9948
+ );
9949
+ await persistProjectMap(projectMap, config.pluginRoot);
9950
+ const needsEslint = projectMap.projects.some(
9951
+ (p) => (p.type === "typescript" || p.type === "javascript") && !p.scannerAvailable
9952
+ );
9953
+ if (needsEslint) {
9954
+ logger.info("monorepo: JS/TS projects detected but ESLint not installed \u2014 bootstrapping");
9955
+ try {
9956
+ await bootstrapScanner(config.pluginRoot, sarifStore, logger);
9957
+ projectMap = await discoverProjectMap(config.pluginRoot, { projectDirs: userProjectDirs });
9958
+ await persistProjectMap(projectMap, config.pluginRoot);
9959
+ } catch (err) {
9960
+ logger.warn({ err: err.message }, "monorepo ESLint bootstrap failed");
9961
+ }
9962
+ }
9963
+ }
9964
+ } catch (err) {
9965
+ logger.warn({ err: err.message }, "project map discovery failed");
9966
+ }
9635
9967
  let dashboard = null;
9636
9968
  try {
9637
9969
  dashboard = await startDashboard({
@@ -9718,12 +10050,20 @@ async function main() {
9718
10050
  name: "bootstrap_scanner",
9719
10051
  description: "Detect project type, install the right scanner (ESLint for JS/TS, Bandit for Python, Semgrep for Java/C#), create minimal config, and run auto_scan to verify.",
9720
10052
  inputSchema: bootstrapScannerSchema
10053
+ },
10054
+ {
10055
+ name: "list_projects",
10056
+ description: "List all discovered sub-projects in the workspace. In a monorepo, returns each sub-project with its type, path, and recommended scanner.",
10057
+ inputSchema: listProjectsSchema
9721
10058
  }
9722
10059
  ]
9723
10060
  }));
9724
10061
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
9725
10062
  const { name, arguments: args } = request.params;
9726
10063
  logger.info({ tool: name }, "Tool call received");
10064
+ return handleToolCall(name, args);
10065
+ });
10066
+ async function handleToolCall(name, args) {
9727
10067
  switch (name) {
9728
10068
  case "compute_crap": {
9729
10069
  const typed = args;
@@ -9809,10 +10149,18 @@ async function main() {
9809
10149
  case "score_project": {
9810
10150
  const typed = args ?? {};
9811
10151
  const format = typed.format ?? "both";
10152
+ let scoreRoot = config.pluginRoot;
10153
+ if (typed.scope && projectMap) {
10154
+ const project = projectMap.projects.find((p) => p.name === typed.scope);
10155
+ if (project) {
10156
+ const { join: join13 } = await import("node:path");
10157
+ scoreRoot = join13(config.pluginRoot, project.path);
10158
+ }
10159
+ }
9812
10160
  try {
9813
- const workspace = await estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions });
10161
+ const workspace = await estimateWorkspaceLoc(scoreRoot, { exclude: userExclusions });
9814
10162
  const score = computeProjectScore({
9815
- workspaceRoot: config.pluginRoot,
10163
+ workspaceRoot: scoreRoot,
9816
10164
  minutesPerLoc: config.minutesPerLoc,
9817
10165
  tdrMaxRating: config.tdrMaxRating,
9818
10166
  workspace: { physicalLoc: workspace.physicalLoc, fileCount: workspace.fileCount },
@@ -10060,10 +10408,28 @@ async function main() {
10060
10408
  };
10061
10409
  }
10062
10410
  }
10411
+ case "list_projects": {
10412
+ return {
10413
+ content: [
10414
+ {
10415
+ type: "text",
10416
+ text: JSON.stringify(
10417
+ {
10418
+ tool: "list_projects",
10419
+ isMonorepo: projectMap?.isMonorepo ?? false,
10420
+ projects: projectMap?.projects ?? []
10421
+ },
10422
+ null,
10423
+ 2
10424
+ )
10425
+ }
10426
+ ]
10427
+ };
10428
+ }
10063
10429
  default:
10064
10430
  throw new Error(`[claude-crap] Unknown tool: ${name}`);
10065
10431
  }
10066
- });
10432
+ }
10067
10433
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
10068
10434
  resources: [
10069
10435
  {