claude-crap 0.3.6 → 0.3.8

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 (88) hide show
  1. package/README.md +25 -0
  2. package/dist/adapters/common.d.ts +1 -1
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +1 -1
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/adapters/dart-analyzer.d.ts +41 -0
  7. package/dist/adapters/dart-analyzer.d.ts.map +1 -0
  8. package/dist/adapters/dart-analyzer.js +120 -0
  9. package/dist/adapters/dart-analyzer.js.map +1 -0
  10. package/dist/adapters/index.d.ts +1 -0
  11. package/dist/adapters/index.d.ts.map +1 -1
  12. package/dist/adapters/index.js +4 -0
  13. package/dist/adapters/index.js.map +1 -1
  14. package/dist/crap-config.d.ts +2 -0
  15. package/dist/crap-config.d.ts.map +1 -1
  16. package/dist/crap-config.js +36 -28
  17. package/dist/crap-config.js.map +1 -1
  18. package/dist/dashboard/file-detail.d.ts +77 -0
  19. package/dist/dashboard/file-detail.d.ts.map +1 -0
  20. package/dist/dashboard/file-detail.js +120 -0
  21. package/dist/dashboard/file-detail.js.map +1 -0
  22. package/dist/dashboard/server.d.ts +5 -0
  23. package/dist/dashboard/server.d.ts.map +1 -1
  24. package/dist/dashboard/server.js +103 -1
  25. package/dist/dashboard/server.js.map +1 -1
  26. package/dist/index.js +36 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/metrics/workspace-walker.d.ts +4 -1
  29. package/dist/metrics/workspace-walker.d.ts.map +1 -1
  30. package/dist/metrics/workspace-walker.js +12 -28
  31. package/dist/metrics/workspace-walker.js.map +1 -1
  32. package/dist/scanner/auto-scan.d.ts +9 -1
  33. package/dist/scanner/auto-scan.d.ts.map +1 -1
  34. package/dist/scanner/auto-scan.js +27 -5
  35. package/dist/scanner/auto-scan.js.map +1 -1
  36. package/dist/scanner/bootstrap.d.ts +1 -1
  37. package/dist/scanner/bootstrap.d.ts.map +1 -1
  38. package/dist/scanner/bootstrap.js +9 -0
  39. package/dist/scanner/bootstrap.js.map +1 -1
  40. package/dist/scanner/complexity-scanner.d.ts +56 -0
  41. package/dist/scanner/complexity-scanner.d.ts.map +1 -0
  42. package/dist/scanner/complexity-scanner.js +161 -0
  43. package/dist/scanner/complexity-scanner.js.map +1 -0
  44. package/dist/scanner/detector.d.ts +24 -4
  45. package/dist/scanner/detector.d.ts.map +1 -1
  46. package/dist/scanner/detector.js +105 -10
  47. package/dist/scanner/detector.js.map +1 -1
  48. package/dist/scanner/runner.d.ts +4 -1
  49. package/dist/scanner/runner.d.ts.map +1 -1
  50. package/dist/scanner/runner.js +12 -3
  51. package/dist/scanner/runner.js.map +1 -1
  52. package/dist/schemas/tool-schemas.d.ts +1 -1
  53. package/dist/schemas/tool-schemas.js +1 -1
  54. package/dist/schemas/tool-schemas.js.map +1 -1
  55. package/dist/shared/exclusions.d.ts +53 -0
  56. package/dist/shared/exclusions.d.ts.map +1 -0
  57. package/dist/shared/exclusions.js +126 -0
  58. package/dist/shared/exclusions.js.map +1 -0
  59. package/package.json +3 -1
  60. package/plugin/.claude-plugin/plugin.json +1 -1
  61. package/plugin/bundle/dashboard/public/index.html +432 -12
  62. package/plugin/bundle/mcp-server.mjs +747 -137
  63. package/plugin/bundle/mcp-server.mjs.map +4 -4
  64. package/plugin/package-lock.json +15 -2
  65. package/plugin/package.json +2 -1
  66. package/scripts/bundle-plugin.mjs +2 -1
  67. package/src/adapters/common.ts +1 -1
  68. package/src/adapters/dart-analyzer.ts +161 -0
  69. package/src/adapters/index.ts +4 -0
  70. package/src/crap-config.ts +55 -18
  71. package/src/dashboard/file-detail.ts +195 -0
  72. package/src/dashboard/public/index.html +432 -12
  73. package/src/dashboard/server.ts +140 -1
  74. package/src/index.ts +37 -4
  75. package/src/metrics/workspace-walker.ts +15 -27
  76. package/src/scanner/auto-scan.ts +41 -4
  77. package/src/scanner/bootstrap.ts +11 -0
  78. package/src/scanner/complexity-scanner.ts +222 -0
  79. package/src/scanner/detector.ts +114 -10
  80. package/src/scanner/runner.ts +12 -2
  81. package/src/schemas/tool-schemas.ts +1 -1
  82. package/src/shared/exclusions.ts +156 -0
  83. package/src/tests/adapters/dispatch.test.ts +2 -2
  84. package/src/tests/auto-scan.test.ts +2 -2
  85. package/src/tests/complexity-scanner.test.ts +263 -0
  86. package/src/tests/exclusions.test.ts +117 -0
  87. package/src/tests/file-detail-api.test.ts +258 -0
  88. 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 = resolve7.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 resolve7(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 resolve7(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, relative2, 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
- relative2 = parse(serialize(relative2, options), options);
3592
+ relative4 = parse(serialize(relative4, options), options);
3593
3593
  }
3594
3594
  options = options || {};
3595
- if (!options.tolerant && relative2.scheme) {
3596
- target.scheme = relative2.scheme;
3597
- target.userinfo = relative2.userinfo;
3598
- target.host = relative2.host;
3599
- target.port = relative2.port;
3600
- target.path = removeDotSegments(relative2.path || "");
3601
- target.query = relative2.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 (relative2.userinfo !== void 0 || relative2.host !== void 0 || relative2.port !== void 0) {
3604
- target.userinfo = relative2.userinfo;
3605
- target.host = relative2.host;
3606
- target.port = relative2.port;
3607
- target.path = removeDotSegments(relative2.path || "");
3608
- target.query = relative2.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 (!relative2.path) {
3610
+ if (!relative4.path) {
3611
3611
  target.path = base.path;
3612
- if (relative2.query !== void 0) {
3613
- target.query = relative2.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 (relative2.path[0] === "/") {
3619
- target.path = removeDotSegments(relative2.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 = "/" + relative2.path;
3622
+ target.path = "/" + relative4.path;
3623
3623
  } else if (!base.path) {
3624
- target.path = relative2.path;
3624
+ target.path = relative4.path;
3625
3625
  } else {
3626
- target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative2.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 = relative2.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 = relative2.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: resolve7,
3810
3810
  resolveComponent,
3811
3811
  equal,
3812
3812
  serialize,
@@ -6853,6 +6853,84 @@ 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
+
6856
6934
  // src/adapters/index.ts
6857
6935
  function adaptScannerOutput(scanner, rawOutput) {
6858
6936
  switch (scanner) {
@@ -6864,6 +6942,8 @@ function adaptScannerOutput(scanner, rawOutput) {
6864
6942
  return adaptBandit(rawOutput);
6865
6943
  case "stryker":
6866
6944
  return adaptStryker(rawOutput);
6945
+ case "dart_analyze":
6946
+ return adaptDartAnalyzer(rawOutput);
6867
6947
  default: {
6868
6948
  const exhaustive = scanner;
6869
6949
  throw new Error(`[adapters] Unknown scanner: ${String(exhaustive)}`);
@@ -7041,6 +7121,15 @@ var LANGUAGE_TABLE = {
7041
7121
  python: PYTHON,
7042
7122
  java: JAVA
7043
7123
  };
7124
+ function detectLanguageFromPath(filePath) {
7125
+ const lower = filePath.toLowerCase();
7126
+ for (const config of Object.values(LANGUAGE_TABLE)) {
7127
+ for (const ext of config.extensions) {
7128
+ if (lower.endsWith(ext)) return config.id;
7129
+ }
7130
+ }
7131
+ return null;
7132
+ }
7044
7133
 
7045
7134
  // src/ast/tree-sitter-engine.ts
7046
7135
  var TreeSitterEngine = class {
@@ -7235,12 +7324,106 @@ function loadConfig() {
7235
7324
  }
7236
7325
 
7237
7326
  // src/dashboard/server.ts
7238
- import { promises as fs2, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
7239
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
7327
+ import { promises as fs3, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
7328
+ import { dirname as dirname2, join as join2, resolve as resolve3 } from "node:path";
7240
7329
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7241
7330
  import Fastify from "fastify";
7242
7331
  import fastifyStatic from "@fastify/static";
7243
7332
 
7333
+ // src/shared/exclusions.ts
7334
+ import picomatch from "picomatch";
7335
+ var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
7336
+ // Package managers / vendored deps
7337
+ "node_modules",
7338
+ "vendor",
7339
+ // Version control
7340
+ ".git",
7341
+ // Build outputs (general)
7342
+ "dist",
7343
+ "build",
7344
+ "bundle",
7345
+ "out",
7346
+ "target",
7347
+ "coverage",
7348
+ // Framework build outputs
7349
+ ".next",
7350
+ // Next.js
7351
+ ".nuxt",
7352
+ // Nuxt 2
7353
+ ".output",
7354
+ // Nuxt 3
7355
+ ".vercel",
7356
+ // Vercel
7357
+ ".svelte-kit",
7358
+ // SvelteKit
7359
+ ".astro",
7360
+ // Astro
7361
+ ".angular",
7362
+ // Angular
7363
+ ".turbo",
7364
+ // Turborepo
7365
+ ".parcel-cache",
7366
+ // Parcel
7367
+ ".expo",
7368
+ // Expo / React Native
7369
+ // Language-specific caches
7370
+ ".venv",
7371
+ "venv",
7372
+ "__pycache__",
7373
+ ".cache",
7374
+ ".dart_tool",
7375
+ // Dart / Flutter
7376
+ ".gradle",
7377
+ // Gradle
7378
+ // IDE state
7379
+ ".idea",
7380
+ // Plugin state
7381
+ ".claude-crap",
7382
+ ".codesight"
7383
+ ]);
7384
+ var DEFAULT_SKIP_PATTERNS = [
7385
+ "*.min.js",
7386
+ "*.min.css",
7387
+ "*.min.mjs",
7388
+ "*.min.cjs",
7389
+ "*.bundle.js",
7390
+ "*.chunk.js"
7391
+ ];
7392
+ function createExclusionFilter(userExclusions) {
7393
+ const extraDirs = /* @__PURE__ */ new Set();
7394
+ const fileGlobs = [];
7395
+ for (const pattern of userExclusions ?? []) {
7396
+ if (pattern.endsWith("/")) {
7397
+ extraDirs.add(pattern.slice(0, -1));
7398
+ } else {
7399
+ fileGlobs.push(pattern);
7400
+ }
7401
+ }
7402
+ const defaultFileMatchers = DEFAULT_SKIP_PATTERNS.map(
7403
+ (p) => picomatch(p, { dot: true })
7404
+ );
7405
+ const userFileMatchers = fileGlobs.map(
7406
+ (p) => picomatch(p, { dot: true })
7407
+ );
7408
+ return {
7409
+ shouldSkipDir(dirName) {
7410
+ if (dirName.startsWith(".") && dirName !== ".claude-plugin") {
7411
+ return DEFAULT_SKIP_DIRS.has(dirName) || true;
7412
+ }
7413
+ return DEFAULT_SKIP_DIRS.has(dirName) || extraDirs.has(dirName);
7414
+ },
7415
+ shouldSkipFile(relativePath, fileName) {
7416
+ for (const matcher of defaultFileMatchers) {
7417
+ if (matcher(fileName)) return true;
7418
+ }
7419
+ for (const matcher of userFileMatchers) {
7420
+ if (matcher(relativePath) || matcher(fileName)) return true;
7421
+ }
7422
+ return false;
7423
+ }
7424
+ };
7425
+ }
7426
+
7244
7427
  // src/metrics/tdr.ts
7245
7428
  var RATING_ORDER = ["A", "B", "C", "D", "E"];
7246
7429
  function ratingToRank(rating) {
@@ -7403,6 +7586,105 @@ function renderProjectScoreMarkdown(score) {
7403
7586
  ].join("\n");
7404
7587
  }
7405
7588
 
7589
+ // src/dashboard/file-detail.ts
7590
+ import { promises as fs2 } from "node:fs";
7591
+
7592
+ // src/workspace-guard.ts
7593
+ import { isAbsolute, resolve as resolve2, sep } from "node:path";
7594
+ function resolveWithinWorkspace(workspaceRoot, filePath) {
7595
+ const workspace = resolve2(workspaceRoot);
7596
+ const candidate = isAbsolute(filePath) ? resolve2(filePath) : resolve2(workspace, filePath);
7597
+ if (candidate !== workspace && !candidate.startsWith(workspace + sep)) {
7598
+ throw new Error(
7599
+ `[claude-crap] Refusing to access '${filePath}' \u2014 path escapes the workspace root`
7600
+ );
7601
+ }
7602
+ return candidate;
7603
+ }
7604
+
7605
+ // src/dashboard/file-detail.ts
7606
+ async function buildFileDetail(input) {
7607
+ const { relativePath, workspaceRoot, astEngine, sarifStore, cyclomaticMax } = input;
7608
+ const absolutePath = resolveWithinWorkspace(workspaceRoot, relativePath);
7609
+ const source = await fs2.readFile(absolutePath, "utf8");
7610
+ const sourceLines = source.split(/\r?\n/);
7611
+ if (sourceLines.length > 0 && sourceLines[sourceLines.length - 1] === "") {
7612
+ sourceLines.pop();
7613
+ }
7614
+ const physicalLoc = sourceLines.length;
7615
+ let logicalLoc = 0;
7616
+ for (const line of sourceLines) {
7617
+ if (line.trim().length > 0) logicalLoc += 1;
7618
+ }
7619
+ const language = detectLanguageFromPath(relativePath);
7620
+ let functions = [];
7621
+ if (language && astEngine) {
7622
+ try {
7623
+ const metrics = await astEngine.analyzeFile({
7624
+ filePath: absolutePath,
7625
+ language
7626
+ });
7627
+ functions = metrics.functions.map((fn) => ({
7628
+ name: fn.name,
7629
+ startLine: fn.startLine,
7630
+ endLine: fn.endLine,
7631
+ cyclomaticComplexity: fn.cyclomaticComplexity,
7632
+ lineCount: fn.lineCount
7633
+ }));
7634
+ } catch {
7635
+ }
7636
+ }
7637
+ const allFindings = sarifStore.list();
7638
+ const fileFindings = allFindings.filter(
7639
+ (f) => f.location.uri === relativePath
7640
+ );
7641
+ const findings = fileFindings.map((f) => ({
7642
+ ruleId: f.ruleId,
7643
+ level: f.level,
7644
+ message: f.message,
7645
+ sourceTool: f.sourceTool,
7646
+ startLine: f.location.startLine,
7647
+ startColumn: f.location.startColumn,
7648
+ endLine: f.location.endLine ?? f.location.startLine,
7649
+ endColumn: f.location.endColumn ?? 0,
7650
+ effortMinutes: typeof f.properties?.effortMinutes === "number" ? f.properties.effortMinutes : 0
7651
+ }));
7652
+ let errorCount = 0;
7653
+ let warningCount = 0;
7654
+ let noteCount = 0;
7655
+ let totalEffortMinutes = 0;
7656
+ for (const f of findings) {
7657
+ if (f.level === "error") errorCount += 1;
7658
+ else if (f.level === "warning") warningCount += 1;
7659
+ else if (f.level === "note") noteCount += 1;
7660
+ totalEffortMinutes += f.effortMinutes;
7661
+ }
7662
+ const complexities = functions.map((f) => f.cyclomaticComplexity);
7663
+ const maxComplexity = complexities.length > 0 ? Math.max(...complexities) : 0;
7664
+ const avgComplexity = complexities.length > 0 ? Math.round(
7665
+ complexities.reduce((a, b) => a + b, 0) / complexities.length * 100
7666
+ ) / 100 : 0;
7667
+ return {
7668
+ filePath: relativePath,
7669
+ language,
7670
+ physicalLoc,
7671
+ logicalLoc,
7672
+ cyclomaticMax,
7673
+ sourceLines,
7674
+ functions,
7675
+ findings,
7676
+ summary: {
7677
+ totalFindings: findings.length,
7678
+ errorCount,
7679
+ warningCount,
7680
+ noteCount,
7681
+ totalEffortMinutes,
7682
+ avgComplexity,
7683
+ maxComplexity
7684
+ }
7685
+ };
7686
+ }
7687
+
7406
7688
  // src/dashboard/server.ts
7407
7689
  async function startDashboard(options) {
7408
7690
  const { config, sarifStore, workspaceStatsProvider, logger: logger2 } = options;
@@ -7416,13 +7698,45 @@ async function startDashboard(options) {
7416
7698
  root: publicRoot,
7417
7699
  prefix: "/"
7418
7700
  });
7419
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.6" }));
7701
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.8" }));
7420
7702
  fastify.get("/api/score", async () => {
7421
7703
  const stats = await workspaceStatsProvider();
7422
7704
  const score = await buildScore(config, sarifStore, stats, urlOf(fastify, config));
7423
7705
  return score;
7424
7706
  });
7425
7707
  fastify.get("/api/sarif", async () => sarifStore.toSarifDocument());
7708
+ fastify.get("/api/complexity", async () => {
7709
+ if (!options.astEngine) {
7710
+ return { threshold: config.cyclomaticMax, totalFunctions: 0, violationCount: 0, topFunctions: [] };
7711
+ }
7712
+ return buildComplexityReport(config, options.astEngine, logger2, options.exclude);
7713
+ });
7714
+ fastify.get("/api/file-detail", async (request, reply) => {
7715
+ const { path: filePath } = request.query;
7716
+ if (!filePath) {
7717
+ return reply.status(400).send({ error: "Missing required query parameter: path" });
7718
+ }
7719
+ try {
7720
+ const detail = await buildFileDetail({
7721
+ relativePath: filePath,
7722
+ workspaceRoot: config.pluginRoot,
7723
+ astEngine: options.astEngine,
7724
+ sarifStore,
7725
+ cyclomaticMax: config.cyclomaticMax
7726
+ });
7727
+ return detail;
7728
+ } catch (err) {
7729
+ const msg = err.message;
7730
+ if (msg.includes("ENOENT") || msg.includes("not found")) {
7731
+ return reply.status(404).send({ error: `File not found: ${filePath}` });
7732
+ }
7733
+ if (msg.includes("escapes the workspace")) {
7734
+ return reply.status(400).send({ error: msg });
7735
+ }
7736
+ logger2.error({ err: msg, filePath }, "file-detail endpoint error");
7737
+ return reply.status(500).send({ error: "Internal server error" });
7738
+ }
7739
+ });
7426
7740
  fastify.get("/", async (_request, reply) => {
7427
7741
  return reply.sendFile("index.html");
7428
7742
  });
@@ -7444,19 +7758,19 @@ async function resolvePublicRoot(logger2) {
7444
7758
  const here = dirname2(fileURLToPath2(import.meta.url));
7445
7759
  const candidates = [
7446
7760
  // 0. Bundled layout: plugin/bundle/mcp-server.mjs → ./dashboard/public
7447
- resolve2(here, "dashboard", "public"),
7761
+ resolve3(here, "dashboard", "public"),
7448
7762
  // 1. Compiled layout: dist/dashboard/server.js → ./public next to it
7449
7763
  // (only present if a build step copies the assets — not used
7450
7764
  // today, but accepted so a future copy step does not break us).
7451
- resolve2(here, "public"),
7765
+ resolve3(here, "public"),
7452
7766
  // 2. Source-relative layout: dist/dashboard/server.js → ../../src/dashboard/public
7453
7767
  // This is the default — no copy step required because we resolve
7454
7768
  // upward from `dist/` into `src/` at runtime.
7455
- resolve2(here, "..", "..", "src", "dashboard", "public")
7769
+ resolve3(here, "..", "..", "src", "dashboard", "public")
7456
7770
  ];
7457
7771
  for (const candidate of candidates) {
7458
7772
  try {
7459
- await fs2.access(resolve2(candidate, "index.html"));
7773
+ await fs3.access(resolve3(candidate, "index.html"));
7460
7774
  return candidate;
7461
7775
  } catch {
7462
7776
  }
@@ -7541,6 +7855,57 @@ async function killStaleDashboard(pidFilePath, port, logger2) {
7541
7855
  removePidFile(pidFilePath);
7542
7856
  await new Promise((r) => setTimeout(r, 300));
7543
7857
  }
7858
+ async function buildComplexityReport(config, engine, logger2, exclude) {
7859
+ const threshold = config.cyclomaticMax;
7860
+ const filter = createExclusionFilter(exclude);
7861
+ const allFunctions = [];
7862
+ let totalFunctions = 0;
7863
+ async function walk2(dir) {
7864
+ let entries;
7865
+ try {
7866
+ entries = await fs3.readdir(dir, { withFileTypes: true });
7867
+ } catch {
7868
+ return;
7869
+ }
7870
+ for (const entry of entries) {
7871
+ const full = join2(dir, entry.name);
7872
+ if (entry.isDirectory()) {
7873
+ if (filter.shouldSkipDir(entry.name)) continue;
7874
+ await walk2(full);
7875
+ continue;
7876
+ }
7877
+ if (!entry.isFile()) continue;
7878
+ const language = detectLanguageFromPath(entry.name);
7879
+ if (!language) continue;
7880
+ try {
7881
+ const metrics = await engine.analyzeFile({ filePath: full, language });
7882
+ for (const fn of metrics.functions) {
7883
+ totalFunctions += 1;
7884
+ allFunctions.push({
7885
+ filePath: full.startsWith(config.pluginRoot) ? full.substring(config.pluginRoot.length + 1) : full,
7886
+ name: fn.name,
7887
+ cyclomaticComplexity: fn.cyclomaticComplexity,
7888
+ startLine: fn.startLine,
7889
+ endLine: fn.endLine,
7890
+ lineCount: fn.lineCount
7891
+ });
7892
+ }
7893
+ } catch (err) {
7894
+ logger2.warn(
7895
+ { filePath: full, err: err.message },
7896
+ "complexity-report: failed to analyze file"
7897
+ );
7898
+ }
7899
+ }
7900
+ }
7901
+ await walk2(config.pluginRoot);
7902
+ allFunctions.sort((a, b) => b.cyclomaticComplexity - a.cyclomaticComplexity);
7903
+ const topFunctions = allFunctions.slice(0, 20);
7904
+ const violationCount = allFunctions.filter(
7905
+ (f) => f.cyclomaticComplexity > threshold
7906
+ ).length;
7907
+ return { threshold, totalFunctions, violationCount, topFunctions };
7908
+ }
7544
7909
  async function buildScore(config, sarifStore, workspace, dashboardUrl) {
7545
7910
  return computeProjectScore({
7546
7911
  workspaceRoot: config.pluginRoot,
@@ -7583,24 +7948,8 @@ function computeCrap(input, threshold) {
7583
7948
  }
7584
7949
 
7585
7950
  // src/metrics/workspace-walker.ts
7586
- import { promises as fs3 } from "node:fs";
7587
- import { join as join3 } from "node:path";
7588
- var SKIP_DIRS = /* @__PURE__ */ new Set([
7589
- "node_modules",
7590
- ".git",
7591
- "dist",
7592
- "build",
7593
- "out",
7594
- "target",
7595
- ".venv",
7596
- "venv",
7597
- "__pycache__",
7598
- ".cache",
7599
- ".next",
7600
- ".nuxt",
7601
- ".claude-crap",
7602
- ".codesight"
7603
- ]);
7951
+ import { promises as fs4 } from "node:fs";
7952
+ import { join as join3, relative } from "node:path";
7604
7953
  var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
7605
7954
  ".ts",
7606
7955
  ".tsx",
@@ -7624,7 +7973,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
7624
7973
  ".vue"
7625
7974
  ]);
7626
7975
  var MAX_FILES_WALKED = 2e4;
7627
- async function estimateWorkspaceLoc(workspaceRoot) {
7976
+ async function estimateWorkspaceLoc(workspaceRoot, options) {
7977
+ const filter = createExclusionFilter(options?.exclude);
7628
7978
  let physicalLoc = 0;
7629
7979
  let fileCount = 0;
7630
7980
  let truncated = false;
@@ -7632,16 +7982,15 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7632
7982
  if (truncated) return;
7633
7983
  let entries;
7634
7984
  try {
7635
- entries = await fs3.readdir(dir, { withFileTypes: true });
7985
+ entries = await fs4.readdir(dir, { withFileTypes: true });
7636
7986
  } catch {
7637
7987
  return;
7638
7988
  }
7639
7989
  for (const entry of entries) {
7640
7990
  if (truncated) return;
7641
- if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
7642
7991
  const full = join3(dir, entry.name);
7643
7992
  if (entry.isDirectory()) {
7644
- if (SKIP_DIRS.has(entry.name)) continue;
7993
+ if (filter.shouldSkipDir(entry.name)) continue;
7645
7994
  await walk2(full);
7646
7995
  continue;
7647
7996
  }
@@ -7651,13 +8000,15 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7651
8000
  if (dot < 0) continue;
7652
8001
  const ext = lower.substring(dot);
7653
8002
  if (!CODE_EXTENSIONS.has(ext)) continue;
8003
+ const relPath = relative(workspaceRoot, full);
8004
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
7654
8005
  fileCount += 1;
7655
8006
  if (fileCount > MAX_FILES_WALKED) {
7656
8007
  truncated = true;
7657
8008
  return;
7658
8009
  }
7659
8010
  try {
7660
- const content = await fs3.readFile(full, "utf8");
8011
+ const content = await fs4.readFile(full, "utf8");
7661
8012
  if (content.length > 0) {
7662
8013
  const lines = content.split(/\r?\n/).length;
7663
8014
  physicalLoc += content.endsWith("\n") ? lines - 1 : lines;
@@ -7671,8 +8022,8 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7671
8022
  }
7672
8023
 
7673
8024
  // src/sarif/sarif-store.ts
7674
- import { promises as fs4 } from "node:fs";
7675
- import { dirname as dirname3, isAbsolute, join as join4, resolve as resolve3 } from "node:path";
8025
+ import { promises as fs5 } from "node:fs";
8026
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, resolve as resolve4 } from "node:path";
7676
8027
 
7677
8028
  // src/sarif/sarif-builder.ts
7678
8029
  function buildSarifDocument(tool, findings) {
@@ -7734,7 +8085,7 @@ var SarifStore = class {
7734
8085
  /** Tool invocations we have already ingested, for telemetry. */
7735
8086
  toolInvocations = 0;
7736
8087
  constructor(options) {
7737
- const dir = isAbsolute(options.outputDir) ? options.outputDir : resolve3(options.workspaceRoot, options.outputDir);
8088
+ const dir = isAbsolute2(options.outputDir) ? options.outputDir : resolve4(options.workspaceRoot, options.outputDir);
7738
8089
  this.filePath = join4(dir, options.fileName ?? "latest.sarif");
7739
8090
  }
7740
8091
  /**
@@ -7759,7 +8110,7 @@ var SarifStore = class {
7759
8110
  */
7760
8111
  async loadLatest() {
7761
8112
  try {
7762
- const raw = await fs4.readFile(this.filePath, "utf8");
8113
+ const raw = await fs5.readFile(this.filePath, "utf8");
7763
8114
  const parsed = JSON.parse(raw);
7764
8115
  if (parsed.version !== "2.1.0") {
7765
8116
  throw new Error(`Expected SARIF 2.1.0, got ${parsed.version}`);
@@ -7861,10 +8212,10 @@ var SarifStore = class {
7861
8212
  */
7862
8213
  async persist() {
7863
8214
  const doc = this.toSarifDocument();
7864
- await fs4.mkdir(dirname3(this.filePath), { recursive: true });
8215
+ await fs5.mkdir(dirname3(this.filePath), { recursive: true });
7865
8216
  const tmp = `${this.filePath}.${process.pid}.tmp`;
7866
- await fs4.writeFile(tmp, JSON.stringify(doc, null, 2), "utf8");
7867
- await fs4.rename(tmp, this.filePath);
8217
+ await fs5.writeFile(tmp, JSON.stringify(doc, null, 2), "utf8");
8218
+ await fs5.rename(tmp, this.filePath);
7868
8219
  }
7869
8220
  /**
7870
8221
  * Build the current consolidated SARIF document from the in-memory
@@ -8020,6 +8371,8 @@ var CrapConfigError = class extends Error {
8020
8371
  }
8021
8372
  };
8022
8373
  function loadCrapConfig(options) {
8374
+ const fileResult = readFromFile(options.workspaceRoot);
8375
+ const exclude = fileResult?.exclude ?? [];
8023
8376
  const envRaw = process.env["CLAUDE_CRAP_STRICTNESS"];
8024
8377
  if (typeof envRaw === "string" && envRaw.trim() !== "") {
8025
8378
  const normalized = envRaw.trim().toLowerCase();
@@ -8028,11 +8381,12 @@ function loadCrapConfig(options) {
8028
8381
  `[crap-config] CLAUDE_CRAP_STRICTNESS="${envRaw}" is not a valid strictness. Expected one of: ${STRICTNESS_VALUES.join(", ")}.`
8029
8382
  );
8030
8383
  }
8031
- return { strictness: normalized, strictnessSource: "env" };
8384
+ return { strictness: normalized, strictnessSource: "env", exclude };
8385
+ }
8386
+ if (fileResult?.strictness) {
8387
+ return { strictness: fileResult.strictness, strictnessSource: "file", exclude };
8032
8388
  }
8033
- const fromFile = readFromFile(options.workspaceRoot);
8034
- if (fromFile) return { strictness: fromFile, strictnessSource: "file" };
8035
- return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default" };
8389
+ return { strictness: DEFAULT_STRICTNESS, strictnessSource: "default", exclude };
8036
8390
  }
8037
8391
  function readFromFile(workspaceRoot) {
8038
8392
  const filePath = join5(workspaceRoot, ".claude-crap.json");
@@ -8060,43 +8414,63 @@ function readFromFile(workspaceRoot) {
8060
8414
  );
8061
8415
  }
8062
8416
  const doc = parsed;
8063
- if (!("strictness" in doc)) return null;
8064
- const value = doc["strictness"];
8065
- if (typeof value !== "string") {
8066
- throw new CrapConfigError(
8067
- `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`
8068
- );
8417
+ let strictness = null;
8418
+ if ("strictness" in doc) {
8419
+ const value = doc["strictness"];
8420
+ if (typeof value !== "string") {
8421
+ throw new CrapConfigError(
8422
+ `[crap-config] ${filePath}: 'strictness' must be a string, got ${typeof value}`
8423
+ );
8424
+ }
8425
+ const normalized = value.trim().toLowerCase();
8426
+ if (!isStrictness(normalized)) {
8427
+ throw new CrapConfigError(
8428
+ `[crap-config] ${filePath}: 'strictness' is "${value}"; expected one of ${STRICTNESS_VALUES.join(", ")}.`
8429
+ );
8430
+ }
8431
+ strictness = normalized;
8069
8432
  }
8070
- const normalized = value.trim().toLowerCase();
8071
- if (!isStrictness(normalized)) {
8072
- throw new CrapConfigError(
8073
- `[crap-config] ${filePath}: 'strictness' is "${value}"; expected one of ${STRICTNESS_VALUES.join(", ")}.`
8074
- );
8433
+ let exclude = [];
8434
+ if ("exclude" in doc) {
8435
+ const raw2 = doc["exclude"];
8436
+ if (!Array.isArray(raw2)) {
8437
+ throw new CrapConfigError(
8438
+ `[crap-config] ${filePath}: 'exclude' must be an array of strings`
8439
+ );
8440
+ }
8441
+ for (const item of raw2) {
8442
+ if (typeof item !== "string") {
8443
+ throw new CrapConfigError(
8444
+ `[crap-config] ${filePath}: every entry in 'exclude' must be a string, got ${typeof item}`
8445
+ );
8446
+ }
8447
+ }
8448
+ exclude = raw2;
8075
8449
  }
8076
- return normalized;
8450
+ return { strictness, exclude };
8077
8451
  }
8078
8452
  function isStrictness(value) {
8079
8453
  return STRICTNESS_VALUES.includes(value);
8080
8454
  }
8081
8455
 
8082
8456
  // src/tools/test-harness.ts
8083
- import { promises as fs5 } from "node:fs";
8084
- import { basename, dirname as dirname4, extname, isAbsolute as isAbsolute2, join as join6, relative, resolve as resolve4, sep } from "node:path";
8457
+ import { promises as fs6 } from "node:fs";
8458
+ import { basename, dirname as dirname4, extname, isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve as resolve5, sep as sep2 } from "node:path";
8085
8459
  var TEST_SUFFIX_PATTERN = /\.(test|spec)\./;
8086
8460
  function isTestFile(filePath) {
8087
8461
  const base = basename(filePath);
8088
8462
  if (TEST_SUFFIX_PATTERN.test(base)) return true;
8089
8463
  if (base.startsWith("test_") && base.endsWith(".py")) return true;
8090
- const parts = filePath.split(sep);
8464
+ const parts = filePath.split(sep2);
8091
8465
  return parts.includes("__tests__") || parts.includes("tests") || parts.includes("test");
8092
8466
  }
8093
8467
  function candidatePaths(workspaceRoot, filePath) {
8094
- const absSource = resolve4(filePath);
8468
+ const absSource = resolve5(filePath);
8095
8469
  const ext = extname(absSource);
8096
8470
  const base = basename(absSource, ext);
8097
8471
  const dir = dirname4(absSource);
8098
- const absWorkspace = resolve4(workspaceRoot);
8099
- const relFromRoot = relative(absWorkspace, absSource);
8472
+ const absWorkspace = resolve5(workspaceRoot);
8473
+ const relFromRoot = relative2(absWorkspace, absSource);
8100
8474
  const relDir = dirname4(relFromRoot);
8101
8475
  const candidates = /* @__PURE__ */ new Set();
8102
8476
  candidates.add(join6(dir, `${base}.test${ext}`));
@@ -8128,14 +8502,14 @@ function candidatePaths(workspaceRoot, filePath) {
8128
8502
  return Array.from(candidates);
8129
8503
  }
8130
8504
  async function findTestFile(workspaceRoot, filePath) {
8131
- const absolute = isAbsolute2(filePath) ? filePath : resolve4(workspaceRoot, filePath);
8505
+ const absolute = isAbsolute3(filePath) ? filePath : resolve5(workspaceRoot, filePath);
8132
8506
  if (isTestFile(absolute)) {
8133
8507
  return { testFile: absolute, candidates: [absolute], isTestFile: true };
8134
8508
  }
8135
8509
  const candidates = candidatePaths(workspaceRoot, absolute);
8136
8510
  for (const candidate of candidates) {
8137
8511
  try {
8138
- await fs5.access(candidate);
8512
+ await fs6.access(candidate);
8139
8513
  return { testFile: candidate, candidates, isTestFile: false };
8140
8514
  } catch {
8141
8515
  }
@@ -8143,26 +8517,13 @@ async function findTestFile(workspaceRoot, filePath) {
8143
8517
  return { testFile: null, candidates, isTestFile: false };
8144
8518
  }
8145
8519
 
8146
- // src/workspace-guard.ts
8147
- import { isAbsolute as isAbsolute3, resolve as resolve5, sep as sep2 } from "node:path";
8148
- function resolveWithinWorkspace(workspaceRoot, filePath) {
8149
- const workspace = resolve5(workspaceRoot);
8150
- const candidate = isAbsolute3(filePath) ? resolve5(filePath) : resolve5(workspace, filePath);
8151
- if (candidate !== workspace && !candidate.startsWith(workspace + sep2)) {
8152
- throw new Error(
8153
- `[claude-crap] Refusing to access '${filePath}' \u2014 path escapes the workspace root`
8154
- );
8155
- }
8156
- return candidate;
8157
- }
8158
-
8159
8520
  // src/scanner/auto-scan.ts
8160
8521
  import { existsSync as existsSync5 } from "node:fs";
8161
- import { join as join10 } from "node:path";
8522
+ import { join as join11 } from "node:path";
8162
8523
 
8163
8524
  // src/scanner/detector.ts
8164
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
8165
- import { join as join7 } from "node:path";
8525
+ import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync } from "node:fs";
8526
+ import { join as join7, resolve as resolve6 } from "node:path";
8166
8527
  import { execFile } from "node:child_process";
8167
8528
  var SCANNER_SIGNALS = {
8168
8529
  eslint: {
@@ -8211,6 +8572,14 @@ var SCANNER_SIGNALS = {
8211
8572
  ],
8212
8573
  packageJsonKeys: ["@stryker-mutator/core"],
8213
8574
  binaryNames: ["stryker"]
8575
+ },
8576
+ dart_analyze: {
8577
+ configFiles: [
8578
+ "analysis_options.yaml",
8579
+ "pubspec.yaml"
8580
+ ],
8581
+ packageJsonKeys: [],
8582
+ binaryNames: ["dart"]
8214
8583
  }
8215
8584
  };
8216
8585
  function probeConfigFiles(workspaceRoot, scanner) {
@@ -8241,14 +8610,14 @@ function probePackageJson(workspaceRoot, scanner) {
8241
8610
  }
8242
8611
  }
8243
8612
  function probeBinary(binaryName) {
8244
- return new Promise((resolve6) => {
8613
+ return new Promise((resolve7) => {
8245
8614
  execFile("which", [binaryName], { timeout: 5e3 }, (err) => {
8246
- resolve6(err === null);
8615
+ resolve7(err === null);
8247
8616
  });
8248
8617
  });
8249
8618
  }
8250
8619
  async function detectScanners(workspaceRoot) {
8251
- const scanners = ["eslint", "semgrep", "bandit", "stryker"];
8620
+ const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
8252
8621
  const results = await Promise.all(
8253
8622
  scanners.map(async (scanner) => {
8254
8623
  const configProbe = probeConfigFiles(workspaceRoot, scanner);
@@ -8261,10 +8630,13 @@ async function detectScanners(workspaceRoot) {
8261
8630
  };
8262
8631
  }
8263
8632
  if (probePackageJson(workspaceRoot, scanner)) {
8633
+ const binName = SCANNER_SIGNALS[scanner].binaryNames[0];
8634
+ const binPath = binName ? join7(workspaceRoot, "node_modules", ".bin", binName) : null;
8635
+ const installed = binPath !== null && existsSync2(binPath);
8264
8636
  return {
8265
8637
  scanner,
8266
- available: true,
8267
- reason: `found in package.json dependencies`
8638
+ available: installed,
8639
+ reason: installed ? "found in package.json and installed" : `found in package.json but not installed (run \`npm install\`)`
8268
8640
  };
8269
8641
  }
8270
8642
  const signals = SCANNER_SIGNALS[scanner];
@@ -8286,6 +8658,58 @@ async function detectScanners(workspaceRoot) {
8286
8658
  );
8287
8659
  return results;
8288
8660
  }
8661
+ var MONOREPO_DIRS = ["apps", "packages", "libs", "modules", "services"];
8662
+ async function detectMonorepoScanners(workspaceRoot) {
8663
+ const subdirs = /* @__PURE__ */ new Set();
8664
+ try {
8665
+ const pkgPath = join7(workspaceRoot, "package.json");
8666
+ const raw = readFileSync3(pkgPath, "utf-8");
8667
+ const pkg = JSON.parse(raw);
8668
+ if (Array.isArray(pkg.workspaces)) {
8669
+ for (const ws of pkg.workspaces) {
8670
+ if (typeof ws === "string" && !ws.includes("*")) {
8671
+ const full = resolve6(workspaceRoot, ws);
8672
+ if (existsSync2(full)) subdirs.add(full);
8673
+ }
8674
+ }
8675
+ }
8676
+ } catch {
8677
+ }
8678
+ for (const dir of MONOREPO_DIRS) {
8679
+ const full = join7(workspaceRoot, dir);
8680
+ try {
8681
+ const entries = readdirSync(full, { withFileTypes: true });
8682
+ for (const entry of entries) {
8683
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
8684
+ subdirs.add(join7(full, entry.name));
8685
+ }
8686
+ }
8687
+ } catch {
8688
+ }
8689
+ }
8690
+ if (subdirs.size === 0) return [];
8691
+ const detections = [];
8692
+ const scanners = ["eslint", "semgrep", "bandit", "stryker", "dart_analyze"];
8693
+ for (const subdir of subdirs) {
8694
+ for (const scanner of scanners) {
8695
+ const configProbe = probeConfigFiles(subdir, scanner);
8696
+ if (!configProbe.found) continue;
8697
+ if (scanner === "dart_analyze") {
8698
+ const hasBinary = await probeBinary("dart");
8699
+ if (!hasBinary) continue;
8700
+ }
8701
+ const relDir = subdir.replace(workspaceRoot + "/", "");
8702
+ detections.push({
8703
+ scanner,
8704
+ available: true,
8705
+ reason: `config file found in ${relDir}/`,
8706
+ ...configProbe.path ? { configPath: configProbe.path } : {},
8707
+ workingDir: subdir
8708
+ });
8709
+ }
8710
+ }
8711
+ return detections;
8712
+ }
8289
8713
 
8290
8714
  // src/scanner/runner.ts
8291
8715
  import { execFile as execFile2 } from "node:child_process";
@@ -8322,17 +8746,26 @@ function getScannerCommand(scanner, workspaceRoot) {
8322
8746
  nonZeroIsNormal: false,
8323
8747
  outputFile: join8(workspaceRoot, "reports", "mutation", "mutation.json")
8324
8748
  };
8749
+ case "dart_analyze":
8750
+ return {
8751
+ command: "dart",
8752
+ args: ["analyze", "--format=json", "."],
8753
+ timeoutMs: 12e4,
8754
+ nonZeroIsNormal: true
8755
+ // exits 3 when findings exist
8756
+ };
8325
8757
  }
8326
8758
  }
8327
- function runScanner(scanner, workspaceRoot) {
8759
+ function runScanner(scanner, workspaceRoot, options) {
8328
8760
  const start = Date.now();
8329
- const cmd = getScannerCommand(scanner, workspaceRoot);
8330
- return new Promise((resolve6) => {
8761
+ const cwd = options?.workingDir ?? workspaceRoot;
8762
+ const cmd = getScannerCommand(scanner, cwd);
8763
+ return new Promise((resolve7) => {
8331
8764
  execFile2(
8332
8765
  cmd.command,
8333
8766
  cmd.args,
8334
8767
  {
8335
- cwd: workspaceRoot,
8768
+ cwd,
8336
8769
  timeout: cmd.timeoutMs,
8337
8770
  maxBuffer: 50 * 1024 * 1024,
8338
8771
  // 50 MB — large codebases produce verbose output
@@ -8346,7 +8779,7 @@ function runScanner(scanner, workspaceRoot) {
8346
8779
  if (cmd.outputFile && existsSync3(cmd.outputFile)) {
8347
8780
  try {
8348
8781
  const fileOutput = readFileSync4(cmd.outputFile, "utf-8");
8349
- resolve6({
8782
+ resolve7({
8350
8783
  scanner,
8351
8784
  success: true,
8352
8785
  rawOutput: fileOutput,
@@ -8356,7 +8789,7 @@ function runScanner(scanner, workspaceRoot) {
8356
8789
  } catch {
8357
8790
  }
8358
8791
  }
8359
- resolve6({
8792
+ resolve7({
8360
8793
  scanner,
8361
8794
  success: false,
8362
8795
  rawOutput: "",
@@ -8369,7 +8802,7 @@ function runScanner(scanner, workspaceRoot) {
8369
8802
  if (existsSync3(cmd.outputFile)) {
8370
8803
  try {
8371
8804
  const fileOutput = readFileSync4(cmd.outputFile, "utf-8");
8372
- resolve6({
8805
+ resolve7({
8373
8806
  scanner,
8374
8807
  success: true,
8375
8808
  rawOutput: fileOutput,
@@ -8377,7 +8810,7 @@ function runScanner(scanner, workspaceRoot) {
8377
8810
  });
8378
8811
  return;
8379
8812
  } catch (readErr) {
8380
- resolve6({
8813
+ resolve7({
8381
8814
  scanner,
8382
8815
  success: false,
8383
8816
  rawOutput: "",
@@ -8387,7 +8820,7 @@ function runScanner(scanner, workspaceRoot) {
8387
8820
  return;
8388
8821
  }
8389
8822
  }
8390
- resolve6({
8823
+ resolve7({
8391
8824
  scanner,
8392
8825
  success: false,
8393
8826
  rawOutput: "",
@@ -8398,7 +8831,7 @@ function runScanner(scanner, workspaceRoot) {
8398
8831
  }
8399
8832
  const output = stdout.trim();
8400
8833
  if (!output) {
8401
- resolve6({
8834
+ resolve7({
8402
8835
  scanner,
8403
8836
  success: true,
8404
8837
  rawOutput: "[]",
@@ -8407,7 +8840,7 @@ function runScanner(scanner, workspaceRoot) {
8407
8840
  });
8408
8841
  return;
8409
8842
  }
8410
- resolve6({
8843
+ resolve7({
8411
8844
  scanner,
8412
8845
  success: true,
8413
8846
  rawOutput: output,
@@ -8419,7 +8852,7 @@ function runScanner(scanner, workspaceRoot) {
8419
8852
  }
8420
8853
 
8421
8854
  // src/scanner/bootstrap.ts
8422
- import { existsSync as existsSync4, writeFileSync as writeFileSync2, readdirSync } from "node:fs";
8855
+ import { existsSync as existsSync4, writeFileSync as writeFileSync2, readdirSync as readdirSync2 } from "node:fs";
8423
8856
  import { join as join9 } from "node:path";
8424
8857
  import { execFile as execFile3 } from "node:child_process";
8425
8858
  function detectProjectType(workspaceRoot) {
@@ -8436,12 +8869,13 @@ function detectProjectType(workspaceRoot) {
8436
8869
  }
8437
8870
  if (has("Directory.Build.props")) return "csharp";
8438
8871
  try {
8439
- const entries = readdirSync(workspaceRoot);
8872
+ const entries = readdirSync2(workspaceRoot);
8440
8873
  if (entries.some((e) => e.endsWith(".csproj") || e.endsWith(".sln"))) {
8441
8874
  return "csharp";
8442
8875
  }
8443
8876
  } catch {
8444
8877
  }
8878
+ if (has("pubspec.yaml")) return "dart";
8445
8879
  return "unknown";
8446
8880
  }
8447
8881
  function generateEslintConfig(isTypeScript) {
@@ -8483,7 +8917,7 @@ export default [
8483
8917
  `;
8484
8918
  }
8485
8919
  function npmInstall(workspaceRoot, packages) {
8486
- return new Promise((resolve6) => {
8920
+ return new Promise((resolve7) => {
8487
8921
  execFile3(
8488
8922
  "npm",
8489
8923
  ["install", "--save-dev", ...packages],
@@ -8494,14 +8928,14 @@ function npmInstall(workspaceRoot, packages) {
8494
8928
  },
8495
8929
  (err, stdout, stderr) => {
8496
8930
  if (err) {
8497
- resolve6({
8931
+ resolve7({
8498
8932
  action: `npm install --save-dev ${packages.join(" ")}`,
8499
8933
  success: false,
8500
8934
  detail: stderr || err.message
8501
8935
  });
8502
8936
  return;
8503
8937
  }
8504
- resolve6({
8938
+ resolve7({
8505
8939
  action: `npm install --save-dev ${packages.join(" ")}`,
8506
8940
  success: true,
8507
8941
  detail: `installed ${packages.join(", ")}`
@@ -8556,6 +8990,12 @@ function getRecommendation(projectType) {
8556
8990
  canAutoInstall: false,
8557
8991
  installInstructions: "brew install semgrep (or: pip install semgrep, pipx install semgrep)"
8558
8992
  };
8993
+ case "dart":
8994
+ return {
8995
+ scanner: "dart_analyze",
8996
+ canAutoInstall: false,
8997
+ installInstructions: "Install the Dart SDK: https://dart.dev/get-dart (or Flutter SDK which includes Dart)"
8998
+ };
8559
8999
  case "unknown":
8560
9000
  return {
8561
9001
  scanner: "semgrep",
@@ -8700,6 +9140,121 @@ function buildResult(projectType, steps, autoScanResult, recommendation) {
8700
9140
  };
8701
9141
  }
8702
9142
 
9143
+ // src/scanner/complexity-scanner.ts
9144
+ import { promises as fs7 } from "node:fs";
9145
+ import { join as join10, relative as relative3 } from "node:path";
9146
+ var MAX_FILES = 2e4;
9147
+ var RULE_ID = "complexity/cyclomatic-max";
9148
+ var SOURCE_TOOL = "complexity";
9149
+ async function scanComplexity(workspaceRoot, engine, sarifStore, config, logger2) {
9150
+ const start = Date.now();
9151
+ const threshold = config.cyclomaticMax;
9152
+ const errorThreshold = threshold * 2;
9153
+ const filter = createExclusionFilter(config.exclude);
9154
+ const files = await collectSourceFiles(workspaceRoot, filter);
9155
+ logger2.info(
9156
+ { fileCount: files.length, threshold },
9157
+ "complexity-scanner: starting analysis"
9158
+ );
9159
+ const sarifResults = [];
9160
+ let filesScanned = 0;
9161
+ let functionsAnalyzed = 0;
9162
+ let violations = 0;
9163
+ for (const filePath of files) {
9164
+ const language = detectLanguageFromPath(filePath);
9165
+ if (!language) continue;
9166
+ try {
9167
+ const metrics = await engine.analyzeFile({ filePath, language });
9168
+ filesScanned += 1;
9169
+ functionsAnalyzed += metrics.functions.length;
9170
+ for (const fn of metrics.functions) {
9171
+ if (fn.cyclomaticComplexity <= threshold) continue;
9172
+ const level = fn.cyclomaticComplexity >= errorThreshold ? "error" : "warning";
9173
+ const relPath = relative3(workspaceRoot, filePath);
9174
+ sarifResults.push({
9175
+ ruleId: RULE_ID,
9176
+ level,
9177
+ message: {
9178
+ text: `Function '${fn.name}' has cyclomatic complexity ${fn.cyclomaticComplexity} (threshold: ${threshold})`
9179
+ },
9180
+ locations: [
9181
+ {
9182
+ physicalLocation: {
9183
+ artifactLocation: { uri: relPath },
9184
+ region: {
9185
+ startLine: fn.startLine,
9186
+ startColumn: 1,
9187
+ endLine: fn.endLine,
9188
+ endColumn: 1
9189
+ }
9190
+ }
9191
+ }
9192
+ ],
9193
+ properties: {
9194
+ sourceTool: SOURCE_TOOL,
9195
+ effortMinutes: estimateEffortMinutes(level),
9196
+ cyclomaticComplexity: fn.cyclomaticComplexity
9197
+ }
9198
+ });
9199
+ violations += 1;
9200
+ }
9201
+ } catch (err) {
9202
+ logger2.warn(
9203
+ { filePath, err: err.message },
9204
+ "complexity-scanner: failed to analyze file, skipping"
9205
+ );
9206
+ }
9207
+ }
9208
+ if (sarifResults.length > 0) {
9209
+ const document = wrapResultsInSarif(
9210
+ SOURCE_TOOL,
9211
+ "0.1.0",
9212
+ sarifResults
9213
+ );
9214
+ sarifStore.ingestRun(document, SOURCE_TOOL);
9215
+ await sarifStore.persist();
9216
+ }
9217
+ const durationMs = Date.now() - start;
9218
+ logger2.info(
9219
+ { filesScanned, functionsAnalyzed, violations, durationMs },
9220
+ "complexity-scanner: analysis complete"
9221
+ );
9222
+ return { filesScanned, functionsAnalyzed, violations, durationMs };
9223
+ }
9224
+ async function collectSourceFiles(workspaceRoot, filter) {
9225
+ const files = [];
9226
+ let truncated = false;
9227
+ async function walk2(dir) {
9228
+ if (truncated) return;
9229
+ let entries;
9230
+ try {
9231
+ entries = await fs7.readdir(dir, { withFileTypes: true });
9232
+ } catch {
9233
+ return;
9234
+ }
9235
+ for (const entry of entries) {
9236
+ if (truncated) return;
9237
+ const full = join10(dir, entry.name);
9238
+ if (entry.isDirectory()) {
9239
+ if (filter.shouldSkipDir(entry.name)) continue;
9240
+ await walk2(full);
9241
+ continue;
9242
+ }
9243
+ if (!entry.isFile()) continue;
9244
+ if (!detectLanguageFromPath(entry.name)) continue;
9245
+ const relPath = relative3(workspaceRoot, full);
9246
+ if (filter.shouldSkipFile(relPath, entry.name)) continue;
9247
+ files.push(full);
9248
+ if (files.length >= MAX_FILES) {
9249
+ truncated = true;
9250
+ return;
9251
+ }
9252
+ }
9253
+ }
9254
+ await walk2(workspaceRoot);
9255
+ return files;
9256
+ }
9257
+
8703
9258
  // src/scanner/auto-scan.ts
8704
9259
  function ingestScannerRun(scanner, rawOutput, sarifStore) {
8705
9260
  let parsed;
@@ -8712,13 +9267,21 @@ function ingestScannerRun(scanner, rawOutput, sarifStore) {
8712
9267
  const stats = sarifStore.ingestRun(adapted.document, adapted.sourceTool);
8713
9268
  return { accepted: stats.accepted };
8714
9269
  }
8715
- async function autoScan(workspaceRoot, sarifStore, logger2) {
9270
+ async function autoScan(workspaceRoot, sarifStore, logger2, options) {
8716
9271
  const start = Date.now();
8717
9272
  const detected = await detectScanners(workspaceRoot);
9273
+ const monorepoDetected = await detectMonorepoScanners(workspaceRoot);
9274
+ const rootScannerSet = new Set(detected.filter((d) => d.available).map((d) => d.scanner));
9275
+ for (const md of monorepoDetected) {
9276
+ if (!rootScannerSet.has(md.scanner)) {
9277
+ detected.push(md);
9278
+ }
9279
+ }
8718
9280
  const available = detected.filter((d) => d.available);
8719
9281
  logger2.info(
8720
9282
  {
8721
9283
  detected: detected.map((d) => `${d.scanner}:${d.available}`),
9284
+ monorepo: monorepoDetected.length,
8722
9285
  available: available.length
8723
9286
  },
8724
9287
  "auto-scan: detection complete"
@@ -8737,7 +9300,7 @@ async function autoScan(workspaceRoot, sarifStore, logger2) {
8737
9300
  ".eslintrc.json"
8738
9301
  ];
8739
9302
  const eslintDetected = available.some((d) => d.scanner === "eslint");
8740
- const hasEslintConfig = eslintConfigFiles.some((f) => existsSync5(join10(workspaceRoot, f)));
9303
+ const hasEslintConfig = eslintConfigFiles.some((f) => existsSync5(join11(workspaceRoot, f)));
8741
9304
  if (eslintDetected && !hasEslintConfig) {
8742
9305
  logger2.info("auto-scan: ESLint detected but no config \u2014 running bootstrap");
8743
9306
  try {
@@ -8773,7 +9336,7 @@ async function autoScan(workspaceRoot, sarifStore, logger2) {
8773
9336
  };
8774
9337
  }
8775
9338
  const runResults = await Promise.allSettled(
8776
- available.map((d) => runScanner(d.scanner, workspaceRoot))
9339
+ available.map((d) => runScanner(d.scanner, workspaceRoot, d.workingDir ? { workingDir: d.workingDir } : void 0))
8777
9340
  );
8778
9341
  const results = [];
8779
9342
  let totalFindings = 0;
@@ -8847,11 +9410,30 @@ async function autoScan(workspaceRoot, sarifStore, logger2) {
8847
9410
  if (persistNeeded) {
8848
9411
  await sarifStore.persist();
8849
9412
  }
9413
+ let complexityScan;
9414
+ if (options?.engine) {
9415
+ try {
9416
+ complexityScan = await scanComplexity(
9417
+ workspaceRoot,
9418
+ options.engine,
9419
+ sarifStore,
9420
+ { cyclomaticMax: options.cyclomaticMax ?? 15, ...options.exclude ? { exclude: options.exclude } : {} },
9421
+ logger2
9422
+ );
9423
+ totalFindings += complexityScan.violations;
9424
+ } catch (err) {
9425
+ logger2.warn(
9426
+ { err: err.message },
9427
+ "auto-scan: complexity scanner failed \u2014 continuing without it"
9428
+ );
9429
+ }
9430
+ }
8850
9431
  return {
8851
9432
  detected,
8852
9433
  results,
8853
9434
  totalFindings,
8854
- totalDurationMs: Date.now() - start
9435
+ totalDurationMs: Date.now() - start,
9436
+ ...complexityScan ? { complexityScan } : {}
8855
9437
  };
8856
9438
  }
8857
9439
 
@@ -8971,7 +9553,7 @@ var ingestScannerOutputSchema = {
8971
9553
  properties: {
8972
9554
  scanner: {
8973
9555
  type: "string",
8974
- enum: ["semgrep", "eslint", "bandit", "stryker"],
9556
+ enum: ["semgrep", "eslint", "bandit", "stryker", "dart_analyze"],
8975
9557
  description: "Identifier of the producing scanner."
8976
9558
  },
8977
9559
  rawOutput: {
@@ -9031,6 +9613,15 @@ async function main() {
9031
9613
  { config: { ...config, pluginRoot: "<redacted>" } },
9032
9614
  "claude-crap MCP server starting"
9033
9615
  );
9616
+ let userExclusions = [];
9617
+ try {
9618
+ const crapConfig = loadCrapConfig({ workspaceRoot: config.pluginRoot });
9619
+ userExclusions = crapConfig.exclude;
9620
+ if (userExclusions.length > 0) {
9621
+ logger.info({ exclude: userExclusions }, "user exclusions loaded from .claude-crap.json");
9622
+ }
9623
+ } catch {
9624
+ }
9034
9625
  const astEngine = new TreeSitterEngine();
9035
9626
  const sarifStore = new SarifStore({
9036
9627
  workspaceRoot: config.pluginRoot,
@@ -9046,8 +9637,10 @@ async function main() {
9046
9637
  dashboard = await startDashboard({
9047
9638
  config,
9048
9639
  sarifStore,
9049
- workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot),
9050
- logger
9640
+ workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions }),
9641
+ logger,
9642
+ astEngine,
9643
+ exclude: userExclusions
9051
9644
  });
9052
9645
  } catch (err) {
9053
9646
  logger.warn(
@@ -9217,7 +9810,7 @@ async function main() {
9217
9810
  const typed = args ?? {};
9218
9811
  const format = typed.format ?? "both";
9219
9812
  try {
9220
- const workspace = await estimateWorkspaceLoc(config.pluginRoot);
9813
+ const workspace = await estimateWorkspaceLoc(config.pluginRoot, { exclude: userExclusions });
9221
9814
  const score = computeProjectScore({
9222
9815
  workspaceRoot: config.pluginRoot,
9223
9816
  minutesPerLoc: config.minutesPerLoc,
@@ -9438,7 +10031,11 @@ async function main() {
9438
10031
  case "auto_scan": {
9439
10032
  logger.info({ tool: "auto_scan" }, "Tool call received");
9440
10033
  try {
9441
- const result = await autoScan(config.pluginRoot, sarifStore, logger);
10034
+ const result = await autoScan(config.pluginRoot, sarifStore, logger, {
10035
+ engine: astEngine,
10036
+ cyclomaticMax: config.cyclomaticMax,
10037
+ exclude: userExclusions
10038
+ });
9442
10039
  const markdown = renderAutoScanMarkdown(result);
9443
10040
  return {
9444
10041
  content: [
@@ -9514,7 +10111,11 @@ async function main() {
9514
10111
  const transport = new StdioServerTransport();
9515
10112
  await server.connect(transport);
9516
10113
  logger.info("claude-crap MCP server ready (stdio)");
9517
- autoScan(config.pluginRoot, sarifStore, logger).then((result) => {
10114
+ autoScan(config.pluginRoot, sarifStore, logger, {
10115
+ engine: astEngine,
10116
+ cyclomaticMax: config.cyclomaticMax,
10117
+ exclude: userExclusions
10118
+ }).then((result) => {
9518
10119
  const scanners = result.results.filter((r) => r.success).map((r) => r.scanner);
9519
10120
  logger.info(
9520
10121
  {
@@ -9581,6 +10182,15 @@ function renderAutoScanMarkdown(result) {
9581
10182
  }
9582
10183
  lines.push("");
9583
10184
  }
10185
+ if (result.complexityScan) {
10186
+ const cs = result.complexityScan;
10187
+ lines.push("### Cyclomatic complexity scan\n");
10188
+ lines.push(`- Files scanned: **${cs.filesScanned}**`);
10189
+ lines.push(`- Functions analyzed: **${cs.functionsAnalyzed}**`);
10190
+ lines.push(`- Violations: **${cs.violations}**`);
10191
+ lines.push(`- Duration: ${(cs.durationMs / 1e3).toFixed(1)}s`);
10192
+ lines.push("");
10193
+ }
9584
10194
  lines.push(
9585
10195
  `**Total findings ingested:** ${result.totalFindings} in ${(result.totalDurationMs / 1e3).toFixed(1)}s`
9586
10196
  );