claude-crap 0.3.7 → 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 (100) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +74 -7
  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/dart-analyzer.d.ts +41 -0
  8. package/dist/adapters/dart-analyzer.d.ts.map +1 -0
  9. package/dist/adapters/dart-analyzer.js +120 -0
  10. package/dist/adapters/dart-analyzer.js.map +1 -0
  11. package/dist/adapters/dotnet-format.d.ts +35 -0
  12. package/dist/adapters/dotnet-format.d.ts.map +1 -0
  13. package/dist/adapters/dotnet-format.js +96 -0
  14. package/dist/adapters/dotnet-format.js.map +1 -0
  15. package/dist/adapters/index.d.ts +2 -0
  16. package/dist/adapters/index.d.ts.map +1 -1
  17. package/dist/adapters/index.js +8 -0
  18. package/dist/adapters/index.js.map +1 -1
  19. package/dist/crap-config.d.ts +4 -0
  20. package/dist/crap-config.d.ts.map +1 -1
  21. package/dist/crap-config.js +51 -28
  22. package/dist/crap-config.js.map +1 -1
  23. package/dist/dashboard/file-detail.d.ts.map +1 -1
  24. package/dist/dashboard/file-detail.js.map +1 -1
  25. package/dist/dashboard/server.d.ts +2 -0
  26. package/dist/dashboard/server.d.ts.map +1 -1
  27. package/dist/dashboard/server.js +7 -12
  28. package/dist/dashboard/server.js.map +1 -1
  29. package/dist/index.js +89 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/workspace-walker.d.ts +4 -1
  32. package/dist/metrics/workspace-walker.d.ts.map +1 -1
  33. package/dist/metrics/workspace-walker.js +12 -28
  34. package/dist/metrics/workspace-walker.js.map +1 -1
  35. package/dist/monorepo/project-map.d.ts +112 -0
  36. package/dist/monorepo/project-map.d.ts.map +1 -0
  37. package/dist/monorepo/project-map.js +384 -0
  38. package/dist/monorepo/project-map.js.map +1 -0
  39. package/dist/scanner/auto-scan.d.ts +1 -0
  40. package/dist/scanner/auto-scan.d.ts.map +1 -1
  41. package/dist/scanner/auto-scan.js +14 -5
  42. package/dist/scanner/auto-scan.js.map +1 -1
  43. package/dist/scanner/bootstrap.d.ts +1 -1
  44. package/dist/scanner/bootstrap.d.ts.map +1 -1
  45. package/dist/scanner/bootstrap.js +15 -1
  46. package/dist/scanner/bootstrap.js.map +1 -1
  47. package/dist/scanner/complexity-scanner.d.ts +2 -0
  48. package/dist/scanner/complexity-scanner.d.ts.map +1 -1
  49. package/dist/scanner/complexity-scanner.js +11 -26
  50. package/dist/scanner/complexity-scanner.js.map +1 -1
  51. package/dist/scanner/detector.d.ts +24 -4
  52. package/dist/scanner/detector.d.ts.map +1 -1
  53. package/dist/scanner/detector.js +110 -10
  54. package/dist/scanner/detector.js.map +1 -1
  55. package/dist/scanner/runner.d.ts +4 -1
  56. package/dist/scanner/runner.d.ts.map +1 -1
  57. package/dist/scanner/runner.js +25 -3
  58. package/dist/scanner/runner.js.map +1 -1
  59. package/dist/schemas/tool-schemas.d.ts +16 -1
  60. package/dist/schemas/tool-schemas.d.ts.map +1 -1
  61. package/dist/schemas/tool-schemas.js +16 -1
  62. package/dist/schemas/tool-schemas.js.map +1 -1
  63. package/dist/shared/exclusions.d.ts +53 -0
  64. package/dist/shared/exclusions.d.ts.map +1 -0
  65. package/dist/shared/exclusions.js +126 -0
  66. package/dist/shared/exclusions.js.map +1 -0
  67. package/package.json +3 -1
  68. package/plugin/.claude-plugin/plugin.json +1 -1
  69. package/plugin/CLAUDE.md +37 -0
  70. package/plugin/bundle/mcp-server.mjs +762 -144
  71. package/plugin/bundle/mcp-server.mjs.map +4 -4
  72. package/plugin/package-lock.json +15 -2
  73. package/plugin/package.json +2 -1
  74. package/scripts/bundle-plugin.mjs +2 -1
  75. package/src/adapters/common.ts +1 -1
  76. package/src/adapters/dart-analyzer.ts +161 -0
  77. package/src/adapters/dotnet-format.ts +125 -0
  78. package/src/adapters/index.ts +8 -0
  79. package/src/crap-config.ts +78 -18
  80. package/src/dashboard/file-detail.ts +0 -2
  81. package/src/dashboard/server.ts +9 -10
  82. package/src/index.ts +103 -5
  83. package/src/metrics/workspace-walker.ts +15 -27
  84. package/src/monorepo/project-map.ts +476 -0
  85. package/src/scanner/auto-scan.ts +17 -6
  86. package/src/scanner/bootstrap.ts +18 -1
  87. package/src/scanner/complexity-scanner.ts +15 -26
  88. package/src/scanner/detector.ts +119 -10
  89. package/src/scanner/runner.ts +25 -2
  90. package/src/schemas/tool-schemas.ts +17 -1
  91. package/src/shared/exclusions.ts +156 -0
  92. package/src/tests/adapters/dispatch.test.ts +2 -2
  93. package/src/tests/auto-scan.test.ts +2 -2
  94. package/src/tests/boot-monorepo.test.ts +804 -0
  95. package/src/tests/boot-scanner-detection.test.ts +692 -0
  96. package/src/tests/boot-single-project.test.ts +780 -0
  97. package/src/tests/exclusions.test.ts +117 -0
  98. package/src/tests/integration/mcp-server.integration.test.ts +2 -1
  99. package/src/tests/project-map.test.ts +302 -0
  100. package/src/tests/scanner-detector.test.ts +31 -11
@@ -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 = resolve6.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 resolve6(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,55 +3579,55 @@ var require_fast_uri = __commonJS({
3579
3579
  }
3580
3580
  return uri;
3581
3581
  }
3582
- function resolve6(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;
3586
3586
  return serialize(resolved, schemelessOptions);
3587
3587
  }
3588
- function resolveComponent(base, relative3, options, skipNormalization) {
3588
+ function resolveComponent(base, relative4, options, skipNormalization) {
3589
3589
  const target = {};
3590
3590
  if (!skipNormalization) {
3591
3591
  base = parse(serialize(base, options), options);
3592
- relative3 = parse(serialize(relative3, options), options);
3592
+ relative4 = parse(serialize(relative4, options), options);
3593
3593
  }
3594
3594
  options = options || {};
3595
- if (!options.tolerant && relative3.scheme) {
3596
- target.scheme = relative3.scheme;
3597
- target.userinfo = relative3.userinfo;
3598
- target.host = relative3.host;
3599
- target.port = relative3.port;
3600
- target.path = removeDotSegments(relative3.path || "");
3601
- target.query = relative3.query;
3595
+ if (!options.tolerant && relative4.scheme) {
3596
+ target.scheme = relative4.scheme;
3597
+ target.userinfo = relative4.userinfo;
3598
+ target.host = relative4.host;
3599
+ target.port = relative4.port;
3600
+ target.path = removeDotSegments(relative4.path || "");
3601
+ target.query = relative4.query;
3602
3602
  } else {
3603
- if (relative3.userinfo !== void 0 || relative3.host !== void 0 || relative3.port !== void 0) {
3604
- target.userinfo = relative3.userinfo;
3605
- target.host = relative3.host;
3606
- target.port = relative3.port;
3607
- target.path = removeDotSegments(relative3.path || "");
3608
- target.query = relative3.query;
3603
+ if (relative4.userinfo !== void 0 || relative4.host !== void 0 || relative4.port !== void 0) {
3604
+ target.userinfo = relative4.userinfo;
3605
+ target.host = relative4.host;
3606
+ target.port = relative4.port;
3607
+ target.path = removeDotSegments(relative4.path || "");
3608
+ target.query = relative4.query;
3609
3609
  } else {
3610
- if (!relative3.path) {
3610
+ if (!relative4.path) {
3611
3611
  target.path = base.path;
3612
- if (relative3.query !== void 0) {
3613
- target.query = relative3.query;
3612
+ if (relative4.query !== void 0) {
3613
+ target.query = relative4.query;
3614
3614
  } else {
3615
3615
  target.query = base.query;
3616
3616
  }
3617
3617
  } else {
3618
- if (relative3.path[0] === "/") {
3619
- target.path = removeDotSegments(relative3.path);
3618
+ if (relative4.path[0] === "/") {
3619
+ target.path = removeDotSegments(relative4.path);
3620
3620
  } else {
3621
3621
  if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) {
3622
- target.path = "/" + relative3.path;
3622
+ target.path = "/" + relative4.path;
3623
3623
  } else if (!base.path) {
3624
- target.path = relative3.path;
3624
+ target.path = relative4.path;
3625
3625
  } else {
3626
- target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative3.path;
3626
+ target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative4.path;
3627
3627
  }
3628
3628
  target.path = removeDotSegments(target.path);
3629
3629
  }
3630
- target.query = relative3.query;
3630
+ target.query = relative4.query;
3631
3631
  }
3632
3632
  target.userinfo = base.userinfo;
3633
3633
  target.host = base.host;
@@ -3635,7 +3635,7 @@ var require_fast_uri = __commonJS({
3635
3635
  }
3636
3636
  target.scheme = base.scheme;
3637
3637
  }
3638
- target.fragment = relative3.fragment;
3638
+ target.fragment = relative4.fragment;
3639
3639
  return target;
3640
3640
  }
3641
3641
  function equal(uriA, uriB, options) {
@@ -3806,7 +3806,7 @@ var require_fast_uri = __commonJS({
3806
3806
  var fastUri = {
3807
3807
  SCHEMES,
3808
3808
  normalize,
3809
- resolve: resolve6,
3809
+ resolve: resolve8,
3810
3810
  resolveComponent,
3811
3811
  equal,
3812
3812
  serialize,
@@ -6853,6 +6853,145 @@ function buildSarifResult3(opts) {
6853
6853
  };
6854
6854
  }
6855
6855
 
6856
+ // src/adapters/dart-analyzer.ts
6857
+ function mapSeverity3(dartSeverity) {
6858
+ switch (dartSeverity.toUpperCase()) {
6859
+ case "ERROR":
6860
+ return "error";
6861
+ case "WARNING":
6862
+ return "warning";
6863
+ case "INFO":
6864
+ return "note";
6865
+ default:
6866
+ return "warning";
6867
+ }
6868
+ }
6869
+ var EFFORT_BY_SEVERITY = {
6870
+ error: 30,
6871
+ warning: 15,
6872
+ note: 5,
6873
+ none: 0
6874
+ };
6875
+ function adaptDartAnalyzer(rawOutput) {
6876
+ let parsed;
6877
+ if (typeof rawOutput === "string") {
6878
+ try {
6879
+ parsed = JSON.parse(rawOutput);
6880
+ } catch {
6881
+ throw new Error("[dart-analyzer adapter] rawOutput is not valid JSON");
6882
+ }
6883
+ } else if (rawOutput && typeof rawOutput === "object" && "diagnostics" in rawOutput) {
6884
+ parsed = rawOutput;
6885
+ } else {
6886
+ throw new Error(
6887
+ "[dart-analyzer adapter] rawOutput must be a JSON string or an object with a 'diagnostics' array"
6888
+ );
6889
+ }
6890
+ if (!Array.isArray(parsed.diagnostics)) {
6891
+ throw new Error("[dart-analyzer adapter] 'diagnostics' must be an array");
6892
+ }
6893
+ const results = [];
6894
+ let totalEffortMinutes = 0;
6895
+ for (const diag of parsed.diagnostics) {
6896
+ const level = mapSeverity3(diag.severity);
6897
+ const effort = EFFORT_BY_SEVERITY[level] ?? estimateEffortMinutes(level);
6898
+ totalEffortMinutes += effort;
6899
+ results.push({
6900
+ ruleId: diag.code,
6901
+ level,
6902
+ message: {
6903
+ text: diag.problemMessage + (diag.correctionMessage ? ` ${diag.correctionMessage}` : "")
6904
+ },
6905
+ locations: [
6906
+ {
6907
+ physicalLocation: {
6908
+ artifactLocation: {
6909
+ uri: diag.location.file
6910
+ },
6911
+ region: {
6912
+ startLine: diag.location.range.start.line,
6913
+ startColumn: diag.location.range.start.column,
6914
+ endLine: diag.location.range.end.line,
6915
+ endColumn: diag.location.range.end.column
6916
+ }
6917
+ }
6918
+ }
6919
+ ],
6920
+ properties: {
6921
+ effortMinutes: effort,
6922
+ ...diag.documentation ? { helpUri: diag.documentation } : {}
6923
+ }
6924
+ });
6925
+ }
6926
+ return {
6927
+ document: wrapResultsInSarif("dart_analyze", "1.0.0", results),
6928
+ sourceTool: "dart_analyze",
6929
+ findingCount: parsed.diagnostics.length,
6930
+ totalEffortMinutes
6931
+ };
6932
+ }
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
+
6856
6995
  // src/adapters/index.ts
6857
6996
  function adaptScannerOutput(scanner, rawOutput) {
6858
6997
  switch (scanner) {
@@ -6864,6 +7003,10 @@ function adaptScannerOutput(scanner, rawOutput) {
6864
7003
  return adaptBandit(rawOutput);
6865
7004
  case "stryker":
6866
7005
  return adaptStryker(rawOutput);
7006
+ case "dart_analyze":
7007
+ return adaptDartAnalyzer(rawOutput);
7008
+ case "dotnet_format":
7009
+ return adaptDotnetFormat(rawOutput);
6867
7010
  default: {
6868
7011
  const exhaustive = scanner;
6869
7012
  throw new Error(`[adapters] Unknown scanner: ${String(exhaustive)}`);
@@ -7250,6 +7393,100 @@ import { fileURLToPath as fileURLToPath2 } from "node:url";
7250
7393
  import Fastify from "fastify";
7251
7394
  import fastifyStatic from "@fastify/static";
7252
7395
 
7396
+ // src/shared/exclusions.ts
7397
+ import picomatch from "picomatch";
7398
+ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
7399
+ // Package managers / vendored deps
7400
+ "node_modules",
7401
+ "vendor",
7402
+ // Version control
7403
+ ".git",
7404
+ // Build outputs (general)
7405
+ "dist",
7406
+ "build",
7407
+ "bundle",
7408
+ "out",
7409
+ "target",
7410
+ "coverage",
7411
+ // Framework build outputs
7412
+ ".next",
7413
+ // Next.js
7414
+ ".nuxt",
7415
+ // Nuxt 2
7416
+ ".output",
7417
+ // Nuxt 3
7418
+ ".vercel",
7419
+ // Vercel
7420
+ ".svelte-kit",
7421
+ // SvelteKit
7422
+ ".astro",
7423
+ // Astro
7424
+ ".angular",
7425
+ // Angular
7426
+ ".turbo",
7427
+ // Turborepo
7428
+ ".parcel-cache",
7429
+ // Parcel
7430
+ ".expo",
7431
+ // Expo / React Native
7432
+ // Language-specific caches
7433
+ ".venv",
7434
+ "venv",
7435
+ "__pycache__",
7436
+ ".cache",
7437
+ ".dart_tool",
7438
+ // Dart / Flutter
7439
+ ".gradle",
7440
+ // Gradle
7441
+ // IDE state
7442
+ ".idea",
7443
+ // Plugin state
7444
+ ".claude-crap",
7445
+ ".codesight"
7446
+ ]);
7447
+ var DEFAULT_SKIP_PATTERNS = [
7448
+ "*.min.js",
7449
+ "*.min.css",
7450
+ "*.min.mjs",
7451
+ "*.min.cjs",
7452
+ "*.bundle.js",
7453
+ "*.chunk.js"
7454
+ ];
7455
+ function createExclusionFilter(userExclusions) {
7456
+ const extraDirs = /* @__PURE__ */ new Set();
7457
+ const fileGlobs = [];
7458
+ for (const pattern of userExclusions ?? []) {
7459
+ if (pattern.endsWith("/")) {
7460
+ extraDirs.add(pattern.slice(0, -1));
7461
+ } else {
7462
+ fileGlobs.push(pattern);
7463
+ }
7464
+ }
7465
+ const defaultFileMatchers = DEFAULT_SKIP_PATTERNS.map(
7466
+ (p) => picomatch(p, { dot: true })
7467
+ );
7468
+ const userFileMatchers = fileGlobs.map(
7469
+ (p) => picomatch(p, { dot: true })
7470
+ );
7471
+ return {
7472
+ shouldSkipDir(dirName) {
7473
+ if (dirName.startsWith(".") && dirName !== ".claude-plugin") {
7474
+ return DEFAULT_SKIP_DIRS.has(dirName) || true;
7475
+ }
7476
+ return DEFAULT_SKIP_DIRS.has(dirName) || extraDirs.has(dirName);
7477
+ },
7478
+ shouldSkipFile(relativePath, fileName) {
7479
+ for (const matcher of defaultFileMatchers) {
7480
+ if (matcher(fileName)) return true;
7481
+ }
7482
+ for (const matcher of userFileMatchers) {
7483
+ if (matcher(relativePath) || matcher(fileName)) return true;
7484
+ }
7485
+ return false;
7486
+ }
7487
+ };
7488
+ }
7489
+
7253
7490
  // src/metrics/tdr.ts
7254
7491
  var RATING_ORDER = ["A", "B", "C", "D", "E"];
7255
7492
  function ratingToRank(rating) {
@@ -7524,7 +7761,7 @@ async function startDashboard(options) {
7524
7761
  root: publicRoot,
7525
7762
  prefix: "/"
7526
7763
  });
7527
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.7" }));
7764
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.4.0" }));
7528
7765
  fastify.get("/api/score", async () => {
7529
7766
  const stats = await workspaceStatsProvider();
7530
7767
  const score = await buildScore(config, sarifStore, stats, urlOf(fastify, config));
@@ -7535,7 +7772,7 @@ async function startDashboard(options) {
7535
7772
  if (!options.astEngine) {
7536
7773
  return { threshold: config.cyclomaticMax, totalFunctions: 0, violationCount: 0, topFunctions: [] };
7537
7774
  }
7538
- return buildComplexityReport(config, options.astEngine, logger2);
7775
+ return buildComplexityReport(config, options.astEngine, logger2, options.exclude);
7539
7776
  });
7540
7777
  fastify.get("/api/file-detail", async (request, reply) => {
7541
7778
  const { path: filePath } = request.query;
@@ -7681,24 +7918,9 @@ async function killStaleDashboard(pidFilePath, port, logger2) {
7681
7918
  removePidFile(pidFilePath);
7682
7919
  await new Promise((r) => setTimeout(r, 300));
7683
7920
  }
7684
- var SKIP_DIRS = /* @__PURE__ */ new Set([
7685
- "node_modules",
7686
- ".git",
7687
- "dist",
7688
- "build",
7689
- "out",
7690
- "target",
7691
- ".venv",
7692
- "venv",
7693
- "__pycache__",
7694
- ".cache",
7695
- ".next",
7696
- ".nuxt",
7697
- ".claude-crap",
7698
- ".codesight"
7699
- ]);
7700
- async function buildComplexityReport(config, engine, logger2) {
7921
+ async function buildComplexityReport(config, engine, logger2, exclude) {
7701
7922
  const threshold = config.cyclomaticMax;
7923
+ const filter = createExclusionFilter(exclude);
7702
7924
  const allFunctions = [];
7703
7925
  let totalFunctions = 0;
7704
7926
  async function walk2(dir) {
@@ -7709,10 +7931,9 @@ async function buildComplexityReport(config, engine, logger2) {
7709
7931
  return;
7710
7932
  }
7711
7933
  for (const entry of entries) {
7712
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
7713
7934
  const full = join2(dir, entry.name);
7714
7935
  if (entry.isDirectory()) {
7715
- if (SKIP_DIRS.has(entry.name)) continue;
7936
+ if (filter.shouldSkipDir(entry.name)) continue;
7716
7937
  await walk2(full);
7717
7938
  continue;
7718
7939
  }
@@ -7791,23 +8012,7 @@ function computeCrap(input, threshold) {
7791
8012
 
7792
8013
  // src/metrics/workspace-walker.ts
7793
8014
  import { promises as fs4 } from "node:fs";
7794
- import { join as join3 } from "node:path";
7795
- var SKIP_DIRS2 = /* @__PURE__ */ new Set([
7796
- "node_modules",
7797
- ".git",
7798
- "dist",
7799
- "build",
7800
- "out",
7801
- "target",
7802
- ".venv",
7803
- "venv",
7804
- "__pycache__",
7805
- ".cache",
7806
- ".next",
7807
- ".nuxt",
7808
- ".claude-crap",
7809
- ".codesight"
7810
- ]);
8015
+ import { join as join3, relative } from "node:path";
7811
8016
  var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
7812
8017
  ".ts",
7813
8018
  ".tsx",
@@ -7831,7 +8036,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
7831
8036
  ".vue"
7832
8037
  ]);
7833
8038
  var MAX_FILES_WALKED = 2e4;
7834
- async function estimateWorkspaceLoc(workspaceRoot) {
8039
+ async function estimateWorkspaceLoc(workspaceRoot, options) {
8040
+ const filter = createExclusionFilter(options?.exclude);
7835
8041
  let physicalLoc = 0;
7836
8042
  let fileCount = 0;
7837
8043
  let truncated = false;
@@ -7845,10 +8051,9 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7845
8051
  }
7846
8052
  for (const entry of entries) {
7847
8053
  if (truncated) return;
7848
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
7849
8054
  const full = join3(dir, entry.name);
7850
8055
  if (entry.isDirectory()) {
7851
- if (SKIP_DIRS2.has(entry.name)) continue;
8056
+ if (filter.shouldSkipDir(entry.name)) continue;
7852
8057
  await walk2(full);
7853
8058
  continue;
7854
8059
  }
@@ -7858,6 +8063,8 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7858
8063
  if (dot < 0) continue;
7859
8064
  const ext = lower.substring(dot);
7860
8065
  if (!CODE_EXTENSIONS.has(ext)) continue;
8066
+ const relPath = relative(workspaceRoot, full);
8067
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
7861
8068
  fileCount += 1;
7862
8069
  if (fileCount > MAX_FILES_WALKED) {
7863
8070
  truncated = true;
@@ -8227,6 +8434,9 @@ var CrapConfigError = class extends Error {
8227
8434
  }
8228
8435
  };
8229
8436
  function loadCrapConfig(options) {
8437
+ const fileResult = readFromFile(options.workspaceRoot);
8438
+ const exclude = fileResult?.exclude ?? [];
8439
+ const projectDirs = fileResult?.projectDirs ?? [];
8230
8440
  const envRaw = process.env["CLAUDE_CRAP_STRICTNESS"];
8231
8441
  if (typeof envRaw === "string" && envRaw.trim() !== "") {
8232
8442
  const normalized = envRaw.trim().toLowerCase();
@@ -8235,11 +8445,12 @@ function loadCrapConfig(options) {
8235
8445
  `[crap-config] CLAUDE_CRAP_STRICTNESS="${envRaw}" is not a valid strictness. Expected one of: ${STRICTNESS_VALUES.join(", ")}.`
8236
8446
  );
8237
8447
  }
8238
- return { strictness: normalized, strictnessSource: "env" };
8448
+ return { strictness: normalized, strictnessSource: "env", exclude, projectDirs };
8449
+ }
8450
+ if (fileResult?.strictness) {
8451
+ return { strictness: fileResult.strictness, strictnessSource: "file", exclude, projectDirs };
8239
8452
  }
8240
- const fromFile = readFromFile(options.workspaceRoot);
8241
- if (fromFile) return { strictness: fromFile, strictnessSource: "file" };
8242
- return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default" };
8453
+ return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default", exclude, projectDirs };
8243
8454
  }
8244
8455
  function readFromFile(workspaceRoot) {
8245
8456
  const filePath = join5(workspaceRoot, ".claude-crap.json");
@@ -8267,20 +8478,57 @@ function readFromFile(workspaceRoot) {
8267
8478
  );
8268
8479
  }
8269
8480
  const doc = parsed;
8270
- if (!("strictness" in doc)) return null;
8271
- const value = doc["strictness"];
8272
- if (typeof value !== "string") {
8273
- throw new CrapConfigError(
8274
- `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`
8275
- );
8481
+ let strictness = null;
8482
+ if ("strictness" in doc) {
8483
+ const value = doc["strictness"];
8484
+ if (typeof value !== "string") {
8485
+ throw new CrapConfigError(
8486
+ `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`
8487
+ );
8488
+ }
8489
+ const normalized = value.trim().toLowerCase();
8490
+ if (!isStrictness(normalized)) {
8491
+ throw new CrapConfigError(
8492
+ `[crap-config] ${filePath}: 'strictness' is "${value}"; expected one of ${STRICTNESS_VALUES.join(", ")}.`
8493
+ );
8494
+ }
8495
+ strictness = normalized;
8276
8496
  }
8277
- const normalized = value.trim().toLowerCase();
8278
- if (!isStrictness(normalized)) {
8279
- throw new CrapConfigError(
8280
- `[crap-config] ${filePath}: 'strictness' is "${value}"; expected one of ${STRICTNESS_VALUES.join(", ")}.`
8281
- );
8497
+ let exclude = [];
8498
+ if ("exclude" in doc) {
8499
+ const raw2 = doc["exclude"];
8500
+ if (!Array.isArray(raw2)) {
8501
+ throw new CrapConfigError(
8502
+ `[crap-config] ${filePath}: 'exclude' must be an array of strings`
8503
+ );
8504
+ }
8505
+ for (const item of raw2) {
8506
+ if (typeof item !== "string") {
8507
+ throw new CrapConfigError(
8508
+ `[crap-config] ${filePath}: every entry in 'exclude' must be a string, got ${typeof item}`
8509
+ );
8510
+ }
8511
+ }
8512
+ exclude = raw2;
8282
8513
  }
8283
- return normalized;
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 };
8284
8532
  }
8285
8533
  function isStrictness(value) {
8286
8534
  return STRICTNESS_VALUES.includes(value);
@@ -8288,7 +8536,7 @@ function isStrictness(value) {
8288
8536
 
8289
8537
  // src/tools/test-harness.ts
8290
8538
  import { promises as fs6 } from "node:fs";
8291
- import { basename, dirname as dirname4, extname, isAbsolute as isAbsolute3, join as join6, relative, resolve as resolve5, sep as sep2 } from "node:path";
8539
+ import { basename, dirname as dirname4, extname, isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve as resolve5, sep as sep2 } from "node:path";
8292
8540
  var TEST_SUFFIX_PATTERN = /\.(test|spec)\./;
8293
8541
  function isTestFile(filePath) {
8294
8542
  const base = basename(filePath);
@@ -8303,7 +8551,7 @@ function candidatePaths(workspaceRoot, filePath) {
8303
8551
  const base = basename(absSource, ext);
8304
8552
  const dir = dirname4(absSource);
8305
8553
  const absWorkspace = resolve5(workspaceRoot);
8306
- const relFromRoot = relative(absWorkspace, absSource);
8554
+ const relFromRoot = relative2(absWorkspace, absSource);
8307
8555
  const relDir = dirname4(relFromRoot);
8308
8556
  const candidates = /* @__PURE__ */ new Set();
8309
8557
  candidates.add(join6(dir, `${base}.test${ext}`));
@@ -8355,8 +8603,8 @@ import { existsSync as existsSync5 } from "node:fs";
8355
8603
  import { join as join11 } from "node:path";
8356
8604
 
8357
8605
  // src/scanner/detector.ts
8358
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
8359
- import { join as join7 } from "node:path";
8606
+ import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync } from "node:fs";
8607
+ import { join as join7, resolve as resolve6 } from "node:path";
8360
8608
  import { execFile } from "node:child_process";
8361
8609
  var SCANNER_SIGNALS = {
8362
8610
  eslint: {
@@ -8405,6 +8653,19 @@ var SCANNER_SIGNALS = {
8405
8653
  ],
8406
8654
  packageJsonKeys: ["@stryker-mutator/core"],
8407
8655
  binaryNames: ["stryker"]
8656
+ },
8657
+ dart_analyze: {
8658
+ configFiles: [
8659
+ "analysis_options.yaml",
8660
+ "pubspec.yaml"
8661
+ ],
8662
+ packageJsonKeys: [],
8663
+ binaryNames: ["dart"]
8664
+ },
8665
+ dotnet_format: {
8666
+ configFiles: [],
8667
+ packageJsonKeys: [],
8668
+ binaryNames: ["dotnet"]
8408
8669
  }
8409
8670
  };
8410
8671
  function probeConfigFiles(workspaceRoot, scanner) {
@@ -8435,14 +8696,14 @@ function probePackageJson(workspaceRoot, scanner) {
8435
8696
  }
8436
8697
  }
8437
8698
  function probeBinary(binaryName) {
8438
- return new Promise((resolve6) => {
8699
+ return new Promise((resolve8) => {
8439
8700
  execFile("which", [binaryName], { timeout: 5e3 }, (err) => {
8440
- resolve6(err === null);
8701
+ resolve8(err === null);
8441
8702
  });
8442
8703
  });
8443
8704
  }
8444
8705
  async function detectScanners(workspaceRoot) {
8445
- const scanners = ["eslint", "semgrep", "bandit", "stryker"];
8706
+ const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
8446
8707
  const results = await Promise.all(
8447
8708
  scanners.map(async (scanner) => {
8448
8709
  const configProbe = probeConfigFiles(workspaceRoot, scanner);
@@ -8455,10 +8716,13 @@ async function detectScanners(workspaceRoot) {
8455
8716
  };
8456
8717
  }
8457
8718
  if (probePackageJson(workspaceRoot, scanner)) {
8719
+ const binName = SCANNER_SIGNALS[scanner].binaryNames[0];
8720
+ const binPath = binName ? join7(workspaceRoot, "node_modules", ".bin", binName) : null;
8721
+ const installed = binPath !== null && existsSync2(binPath);
8458
8722
  return {
8459
8723
  scanner,
8460
- available: true,
8461
- reason: `found in package.json dependencies`
8724
+ available: installed,
8725
+ reason: installed ? "found in package.json and installed" : `found in package.json but not installed (run \`npm install\`)`
8462
8726
  };
8463
8727
  }
8464
8728
  const signals = SCANNER_SIGNALS[scanner];
@@ -8480,6 +8744,58 @@ async function detectScanners(workspaceRoot) {
8480
8744
  );
8481
8745
  return results;
8482
8746
  }
8747
+ var MONOREPO_DIRS = ["apps", "packages", "libs", "modules", "services"];
8748
+ async function detectMonorepoScanners(workspaceRoot) {
8749
+ const subdirs = /* @__PURE__ */ new Set();
8750
+ try {
8751
+ const pkgPath = join7(workspaceRoot, "package.json");
8752
+ const raw = readFileSync3(pkgPath, "utf-8");
8753
+ const pkg = JSON.parse(raw);
8754
+ if (Array.isArray(pkg.workspaces)) {
8755
+ for (const ws of pkg.workspaces) {
8756
+ if (typeof ws === "string" && !ws.includes("*")) {
8757
+ const full = resolve6(workspaceRoot, ws);
8758
+ if (existsSync2(full)) subdirs.add(full);
8759
+ }
8760
+ }
8761
+ }
8762
+ } catch {
8763
+ }
8764
+ for (const dir of MONOREPO_DIRS) {
8765
+ const full = join7(workspaceRoot, dir);
8766
+ try {
8767
+ const entries = readdirSync(full, { withFileTypes: true });
8768
+ for (const entry of entries) {
8769
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
8770
+ subdirs.add(join7(full, entry.name));
8771
+ }
8772
+ }
8773
+ } catch {
8774
+ }
8775
+ }
8776
+ if (subdirs.size === 0) return [];
8777
+ const detections = [];
8778
+ const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze", "dotnet_format"];
8779
+ for (const subdir of subdirs) {
8780
+ for (const scanner of scanners) {
8781
+ const configProbe = probeConfigFiles(subdir, scanner);
8782
+ if (!configProbe.found) continue;
8783
+ if (scanner === "dart_analyze") {
8784
+ const hasBinary = await probeBinary("dart");
8785
+ if (!hasBinary) continue;
8786
+ }
8787
+ const relDir = subdir.replace(workspaceRoot + "/", "");
8788
+ detections.push({
8789
+ scanner,
8790
+ available: true,
8791
+ reason: `config file found in ${relDir}/`,
8792
+ ...configProbe.path ? { configPath: configProbe.path } : {},
8793
+ workingDir: subdir
8794
+ });
8795
+ }
8796
+ }
8797
+ return detections;
8798
+ }
8483
8799
 
8484
8800
  // src/scanner/runner.ts
8485
8801
  import { execFile as execFile2 } from "node:child_process";
@@ -8516,17 +8832,39 @@ function getScannerCommand(scanner, workspaceRoot) {
8516
8832
  nonZeroIsNormal: false,
8517
8833
  outputFile: join8(workspaceRoot, "reports", "mutation", "mutation.json")
8518
8834
  };
8835
+ case "dart_analyze":
8836
+ return {
8837
+ command: "dart",
8838
+ args: ["analyze", "--format=json", "."],
8839
+ timeoutMs: 12e4,
8840
+ nonZeroIsNormal: true
8841
+ // exits 3 when findings exist
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
+ };
8519
8856
  }
8520
8857
  }
8521
- function runScanner(scanner, workspaceRoot) {
8858
+ function runScanner(scanner, workspaceRoot, options) {
8522
8859
  const start = Date.now();
8523
- const cmd = getScannerCommand(scanner, workspaceRoot);
8524
- return new Promise((resolve6) => {
8860
+ const cwd = options?.workingDir ?? workspaceRoot;
8861
+ const cmd = getScannerCommand(scanner, cwd);
8862
+ return new Promise((resolve8) => {
8525
8863
  execFile2(
8526
8864
  cmd.command,
8527
8865
  cmd.args,
8528
8866
  {
8529
- cwd: workspaceRoot,
8867
+ cwd,
8530
8868
  timeout: cmd.timeoutMs,
8531
8869
  maxBuffer: 50 * 1024 * 1024,
8532
8870
  // 50 MB — large codebases produce verbose output
@@ -8540,7 +8878,7 @@ function runScanner(scanner, workspaceRoot) {
8540
8878
  if (cmd.outputFile && existsSync3(cmd.outputFile)) {
8541
8879
  try {
8542
8880
  const fileOutput = readFileSync4(cmd.outputFile, "utf-8");
8543
- resolve6({
8881
+ resolve8({
8544
8882
  scanner,
8545
8883
  success: true,
8546
8884
  rawOutput: fileOutput,
@@ -8550,7 +8888,7 @@ function runScanner(scanner, workspaceRoot) {
8550
8888
  } catch {
8551
8889
  }
8552
8890
  }
8553
- resolve6({
8891
+ resolve8({
8554
8892
  scanner,
8555
8893
  success: false,
8556
8894
  rawOutput: "",
@@ -8563,7 +8901,7 @@ function runScanner(scanner, workspaceRoot) {
8563
8901
  if (existsSync3(cmd.outputFile)) {
8564
8902
  try {
8565
8903
  const fileOutput = readFileSync4(cmd.outputFile, "utf-8");
8566
- resolve6({
8904
+ resolve8({
8567
8905
  scanner,
8568
8906
  success: true,
8569
8907
  rawOutput: fileOutput,
@@ -8571,7 +8909,7 @@ function runScanner(scanner, workspaceRoot) {
8571
8909
  });
8572
8910
  return;
8573
8911
  } catch (readErr) {
8574
- resolve6({
8912
+ resolve8({
8575
8913
  scanner,
8576
8914
  success: false,
8577
8915
  rawOutput: "",
@@ -8581,7 +8919,7 @@ function runScanner(scanner, workspaceRoot) {
8581
8919
  return;
8582
8920
  }
8583
8921
  }
8584
- resolve6({
8922
+ resolve8({
8585
8923
  scanner,
8586
8924
  success: false,
8587
8925
  rawOutput: "",
@@ -8592,7 +8930,7 @@ function runScanner(scanner, workspaceRoot) {
8592
8930
  }
8593
8931
  const output = stdout.trim();
8594
8932
  if (!output) {
8595
- resolve6({
8933
+ resolve8({
8596
8934
  scanner,
8597
8935
  success: true,
8598
8936
  rawOutput: "[]",
@@ -8601,7 +8939,7 @@ function runScanner(scanner, workspaceRoot) {
8601
8939
  });
8602
8940
  return;
8603
8941
  }
8604
- resolve6({
8942
+ resolve8({
8605
8943
  scanner,
8606
8944
  success: true,
8607
8945
  rawOutput: output,
@@ -8613,7 +8951,7 @@ function runScanner(scanner, workspaceRoot) {
8613
8951
  }
8614
8952
 
8615
8953
  // src/scanner/bootstrap.ts
8616
- import { existsSync as existsSync4, writeFileSync as writeFileSync2, readdirSync } from "node:fs";
8954
+ import { existsSync as existsSync4, writeFileSync as writeFileSync2, readdirSync as readdirSync2 } from "node:fs";
8617
8955
  import { join as join9 } from "node:path";
8618
8956
  import { execFile as execFile3 } from "node:child_process";
8619
8957
  function detectProjectType(workspaceRoot) {
@@ -8630,12 +8968,13 @@ function detectProjectType(workspaceRoot) {
8630
8968
  }
8631
8969
  if (has("Directory.Build.props")) return "csharp";
8632
8970
  try {
8633
- const entries = readdirSync(workspaceRoot);
8971
+ const entries = readdirSync2(workspaceRoot);
8634
8972
  if (entries.some((e) => e.endsWith(".csproj") || e.endsWith(".sln"))) {
8635
8973
  return "csharp";
8636
8974
  }
8637
8975
  } catch {
8638
8976
  }
8977
+ if (has("pubspec.yaml")) return "dart";
8639
8978
  return "unknown";
8640
8979
  }
8641
8980
  function generateEslintConfig(isTypeScript) {
@@ -8677,7 +9016,7 @@ export default [
8677
9016
  `;
8678
9017
  }
8679
9018
  function npmInstall(workspaceRoot, packages) {
8680
- return new Promise((resolve6) => {
9019
+ return new Promise((resolve8) => {
8681
9020
  execFile3(
8682
9021
  "npm",
8683
9022
  ["install", "--save-dev", ...packages],
@@ -8688,14 +9027,14 @@ function npmInstall(workspaceRoot, packages) {
8688
9027
  },
8689
9028
  (err, stdout, stderr) => {
8690
9029
  if (err) {
8691
- resolve6({
9030
+ resolve8({
8692
9031
  action: `npm install --save-dev ${packages.join(" ")}`,
8693
9032
  success: false,
8694
9033
  detail: stderr || err.message
8695
9034
  });
8696
9035
  return;
8697
9036
  }
8698
- resolve6({
9037
+ resolve8({
8699
9038
  action: `npm install --save-dev ${packages.join(" ")}`,
8700
9039
  success: true,
8701
9040
  detail: `installed ${packages.join(", ")}`
@@ -8744,12 +9083,23 @@ function getRecommendation(projectType) {
8744
9083
  installInstructions: "pip install bandit (or: pipx install bandit, poetry add --group dev bandit)"
8745
9084
  };
8746
9085
  case "java":
8747
- case "csharp":
8748
9086
  return {
8749
9087
  scanner: "semgrep",
8750
9088
  canAutoInstall: false,
8751
9089
  installInstructions: "brew install semgrep (or: pip install semgrep, pipx install semgrep)"
8752
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
+ };
9097
+ case "dart":
9098
+ return {
9099
+ scanner: "dart_analyze",
9100
+ canAutoInstall: false,
9101
+ installInstructions: "Install the Dart SDK: https://dart.dev/get-dart (or Flutter SDK which includes Dart)"
9102
+ };
8753
9103
  case "unknown":
8754
9104
  return {
8755
9105
  scanner: "semgrep",
@@ -8896,23 +9246,7 @@ function buildResult(projectType, steps, autoScanResult, recommendation) {
8896
9246
 
8897
9247
  // src/scanner/complexity-scanner.ts
8898
9248
  import { promises as fs7 } from "node:fs";
8899
- import { join as join10, relative as relative2 } from "node:path";
8900
- var SKIP_DIRS3 = /* @__PURE__ */ new Set([
8901
- "node_modules",
8902
- ".git",
8903
- "dist",
8904
- "build",
8905
- "out",
8906
- "target",
8907
- ".venv",
8908
- "venv",
8909
- "__pycache__",
8910
- ".cache",
8911
- ".next",
8912
- ".nuxt",
8913
- ".claude-crap",
8914
- ".codesight"
8915
- ]);
9249
+ import { join as join10, relative as relative3 } from "node:path";
8916
9250
  var MAX_FILES = 2e4;
8917
9251
  var RULE_ID = "complexity/cyclomatic-max";
8918
9252
  var SOURCE_TOOL = "complexity";
@@ -8920,7 +9254,8 @@ async function scanComplexity(workspaceRoot, engine, sarifStore, config, logger2
8920
9254
  const start = Date.now();
8921
9255
  const threshold = config.cyclomaticMax;
8922
9256
  const errorThreshold = threshold * 2;
8923
- const files = await collectSourceFiles(workspaceRoot);
9257
+ const filter = createExclusionFilter(config.exclude);
9258
+ const files = await collectSourceFiles(workspaceRoot, filter);
8924
9259
  logger2.info(
8925
9260
  { fileCount: files.length, threshold },
8926
9261
  "complexity-scanner: starting analysis"
@@ -8939,7 +9274,7 @@ async function scanComplexity(workspaceRoot, engine, sarifStore, config, logger2
8939
9274
  for (const fn of metrics.functions) {
8940
9275
  if (fn.cyclomaticComplexity <= threshold) continue;
8941
9276
  const level = fn.cyclomaticComplexity >= errorThreshold ? "error" : "warning";
8942
- const relPath = relative2(workspaceRoot, filePath);
9277
+ const relPath = relative3(workspaceRoot, filePath);
8943
9278
  sarifResults.push({
8944
9279
  ruleId: RULE_ID,
8945
9280
  level,
@@ -8990,7 +9325,7 @@ async function scanComplexity(workspaceRoot, engine, sarifStore, config, logger2
8990
9325
  );
8991
9326
  return { filesScanned, functionsAnalyzed, violations, durationMs };
8992
9327
  }
8993
- async function collectSourceFiles(workspaceRoot) {
9328
+ async function collectSourceFiles(workspaceRoot, filter) {
8994
9329
  const files = [];
8995
9330
  let truncated = false;
8996
9331
  async function walk2(dir) {
@@ -9003,15 +9338,16 @@ async function collectSourceFiles(workspaceRoot) {
9003
9338
  }
9004
9339
  for (const entry of entries) {
9005
9340
  if (truncated) return;
9006
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
9007
9341
  const full = join10(dir, entry.name);
9008
9342
  if (entry.isDirectory()) {
9009
- if (SKIP_DIRS3.has(entry.name)) continue;
9343
+ if (filter.shouldSkipDir(entry.name)) continue;
9010
9344
  await walk2(full);
9011
9345
  continue;
9012
9346
  }
9013
9347
  if (!entry.isFile()) continue;
9014
9348
  if (!detectLanguageFromPath(entry.name)) continue;
9349
+ const relPath = relative3(workspaceRoot, full);
9350
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
9015
9351
  files.push(full);
9016
9352
  if (files.length >= MAX_FILES) {
9017
9353
  truncated = true;
@@ -9038,10 +9374,18 @@ function ingestScannerRun(scanner, rawOutput, sarifStore) {
9038
9374
  async function autoScan(workspaceRoot, sarifStore, logger2, options) {
9039
9375
  const start = Date.now();
9040
9376
  const detected = await detectScanners(workspaceRoot);
9377
+ const monorepoDetected = await detectMonorepoScanners(workspaceRoot);
9378
+ const rootScannerSet = new Set(detected.filter((d) => d.available).map((d) => d.scanner));
9379
+ for (const md of monorepoDetected) {
9380
+ if (!rootScannerSet.has(md.scanner)) {
9381
+ detected.push(md);
9382
+ }
9383
+ }
9041
9384
  const available = detected.filter((d) => d.available);
9042
9385
  logger2.info(
9043
9386
  {
9044
9387
  detected: detected.map((d) => `${d.scanner}:${d.available}`),
9388
+ monorepo: monorepoDetected.length,
9045
9389
  available: available.length
9046
9390
  },
9047
9391
  "auto-scan: detection complete"
@@ -9096,7 +9440,7 @@ async function autoScan(workspaceRoot, sarifStore, logger2, options) {
9096
9440
  };
9097
9441
  }
9098
9442
  const runResults = await Promise.allSettled(
9099
- available.map((d) => runScanner(d.scanner, workspaceRoot))
9443
+ available.map((d) => runScanner(d.scanner, workspaceRoot, d.workingDir ? { workingDir: d.workingDir } : void 0))
9100
9444
  );
9101
9445
  const results = [];
9102
9446
  let totalFindings = 0;
@@ -9177,7 +9521,7 @@ async function autoScan(workspaceRoot, sarifStore, logger2, options) {
9177
9521
  workspaceRoot,
9178
9522
  options.engine,
9179
9523
  sarifStore,
9180
- { cyclomaticMax: options.cyclomaticMax ?? 15 },
9524
+ { cyclomaticMax: options.cyclomaticMax ?? 15, ...options.exclude ? { exclude: options.exclude } : {} },
9181
9525
  logger2
9182
9526
  );
9183
9527
  totalFindings += complexityScan.violations;
@@ -9197,6 +9541,192 @@ async function autoScan(workspaceRoot, sarifStore, logger2, options) {
9197
9541
  };
9198
9542
  }
9199
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
+
9200
9730
  // src/schemas/tool-schemas.ts
9201
9731
  var computeCrapSchema = {
9202
9732
  type: "object",
@@ -9287,11 +9817,22 @@ var scoreProjectSchema = {
9287
9817
  type: "string",
9288
9818
  enum: ["markdown", "json", "both"],
9289
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."
9290
9824
  }
9291
9825
  },
9292
9826
  required: [],
9293
9827
  additionalProperties: false
9294
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
+ };
9295
9836
  var requireTestHarnessSchema = {
9296
9837
  type: "object",
9297
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.",
@@ -9313,7 +9854,7 @@ var ingestScannerOutputSchema = {
9313
9854
  properties: {
9314
9855
  scanner: {
9315
9856
  type: "string",
9316
- enum: ["semgrep", "eslint", "bandit", "stryker"],
9857
+ enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze", "dotnet_format"],
9317
9858
  description: "Identifier of the producing scanner."
9318
9859
  },
9319
9860
  rawOutput: {
@@ -9373,6 +9914,20 @@ async function main() {
9373
9914
  { config: { ...config, pluginRoot: "<redacted>" } },
9374
9915
  "claude-crap MCP server starting"
9375
9916
  );
9917
+ let userExclusions = [];
9918
+ let userProjectDirs = [];
9919
+ try {
9920
+ const crapConfig = loadCrapConfig({ workspaceRoot: config.pluginRoot });
9921
+ userExclusions = crapConfig.exclude;
9922
+ userProjectDirs = crapConfig.projectDirs;
9923
+ if (userExclusions.length > 0) {
9924
+ logger.info({ exclude: userExclusions }, "user exclusions loaded from .claude-crap.json");
9925
+ }
9926
+ if (userProjectDirs.length > 0) {
9927
+ logger.info({ projectDirs: userProjectDirs }, "user projectDirs loaded from .claude-crap.json");
9928
+ }
9929
+ } catch {
9930
+ }
9376
9931
  const astEngine = new TreeSitterEngine();
9377
9932
  const sarifStore = new SarifStore({
9378
9933
  workspaceRoot: config.pluginRoot,
@@ -9383,14 +9938,41 @@ async function main() {
9383
9938
  { findings: sarifStore.size(), path: sarifStore.consolidatedReportPath },
9384
9939
  "SARIF store ready"
9385
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
+ }
9386
9967
  let dashboard = null;
9387
9968
  try {
9388
9969
  dashboard = await startDashboard({
9389
9970
  config,
9390
9971
  sarifStore,
9391
- workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot),
9972
+ workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions }),
9392
9973
  logger,
9393
- astEngine
9974
+ astEngine,
9975
+ exclude: userExclusions
9394
9976
  });
9395
9977
  } catch (err) {
9396
9978
  logger.warn(
@@ -9468,12 +10050,20 @@ async function main() {
9468
10050
  name: "bootstrap_scanner",
9469
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.",
9470
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
9471
10058
  }
9472
10059
  ]
9473
10060
  }));
9474
10061
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
9475
10062
  const { name, arguments: args } = request.params;
9476
10063
  logger.info({ tool: name }, "Tool call received");
10064
+ return handleToolCall(name, args);
10065
+ });
10066
+ async function handleToolCall(name, args) {
9477
10067
  switch (name) {
9478
10068
  case "compute_crap": {
9479
10069
  const typed = args;
@@ -9559,10 +10149,18 @@ async function main() {
9559
10149
  case "score_project": {
9560
10150
  const typed = args ?? {};
9561
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
+ }
9562
10160
  try {
9563
- const workspace = await estimateWorkspaceLoc(config.pluginRoot);
10161
+ const workspace = await estimateWorkspaceLoc(scoreRoot, { exclude: userExclusions });
9564
10162
  const score = computeProjectScore({
9565
- workspaceRoot: config.pluginRoot,
10163
+ workspaceRoot: scoreRoot,
9566
10164
  minutesPerLoc: config.minutesPerLoc,
9567
10165
  tdrMaxRating: config.tdrMaxRating,
9568
10166
  workspace: { physicalLoc: workspace.physicalLoc, fileCount: workspace.fileCount },
@@ -9783,7 +10381,8 @@ async function main() {
9783
10381
  try {
9784
10382
  const result = await autoScan(config.pluginRoot, sarifStore, logger, {
9785
10383
  engine: astEngine,
9786
- cyclomaticMax: config.cyclomaticMax
10384
+ cyclomaticMax: config.cyclomaticMax,
10385
+ exclude: userExclusions
9787
10386
  });
9788
10387
  const markdown = renderAutoScanMarkdown(result);
9789
10388
  return {
@@ -9809,10 +10408,28 @@ async function main() {
9809
10408
  };
9810
10409
  }
9811
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
+ }
9812
10429
  default:
9813
10430
  throw new Error(`[claude-crap] Unknown tool: ${name}`);
9814
10431
  }
9815
- });
10432
+ }
9816
10433
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
9817
10434
  resources: [
9818
10435
  {
@@ -9862,7 +10479,8 @@ async function main() {
9862
10479
  logger.info("claude-crap MCP server ready (stdio)");
9863
10480
  autoScan(config.pluginRoot, sarifStore, logger, {
9864
10481
  engine: astEngine,
9865
- cyclomaticMax: config.cyclomaticMax
10482
+ cyclomaticMax: config.cyclomaticMax,
10483
+ exclude: userExclusions
9866
10484
  }).then((result) => {
9867
10485
  const scanners = result.results.filter((r) => r.success).map((r) => r.scanner);
9868
10486
  logger.info(