claude-crap 0.3.5 → 0.3.7

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 (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/dashboard/file-detail.d.ts +77 -0
  3. package/dist/dashboard/file-detail.d.ts.map +1 -0
  4. package/dist/dashboard/file-detail.js +120 -0
  5. package/dist/dashboard/file-detail.js.map +1 -0
  6. package/dist/dashboard/server.d.ts +3 -0
  7. package/dist/dashboard/server.d.ts.map +1 -1
  8. package/dist/dashboard/server.js +108 -1
  9. package/dist/dashboard/server.js.map +1 -1
  10. package/dist/index.js +19 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/scanner/auto-scan.d.ts +8 -1
  13. package/dist/scanner/auto-scan.d.ts.map +1 -1
  14. package/dist/scanner/auto-scan.js +14 -1
  15. package/dist/scanner/auto-scan.js.map +1 -1
  16. package/dist/scanner/complexity-scanner.d.ts +54 -0
  17. package/dist/scanner/complexity-scanner.d.ts.map +1 -0
  18. package/dist/scanner/complexity-scanner.js +176 -0
  19. package/dist/scanner/complexity-scanner.js.map +1 -0
  20. package/package.json +1 -1
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/bundle/dashboard/public/index.html +432 -12
  23. package/plugin/bundle/mcp-server.mjs +429 -71
  24. package/plugin/bundle/mcp-server.mjs.map +4 -4
  25. package/plugin/package-lock.json +2 -2
  26. package/plugin/package.json +1 -1
  27. package/scripts/bundle-plugin.mjs +53 -2
  28. package/src/dashboard/file-detail.ts +197 -0
  29. package/src/dashboard/public/index.html +432 -12
  30. package/src/dashboard/server.ts +141 -1
  31. package/src/index.ts +20 -2
  32. package/src/scanner/auto-scan.ts +26 -0
  33. package/src/scanner/complexity-scanner.ts +233 -0
  34. package/src/tests/complexity-scanner.test.ts +263 -0
  35. package/src/tests/file-detail-api.test.ts +258 -0
@@ -3585,49 +3585,49 @@ var require_fast_uri = __commonJS({
3585
3585
  schemelessOptions.skipEscape = true;
3586
3586
  return serialize(resolved, schemelessOptions);
3587
3587
  }
3588
- function resolveComponent(base, relative2, options, skipNormalization) {
3588
+ function resolveComponent(base, relative3, 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
+ relative3 = parse(serialize(relative3, 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 && 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;
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 (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;
3609
3609
  } else {
3610
- if (!relative2.path) {
3610
+ if (!relative3.path) {
3611
3611
  target.path = base.path;
3612
- if (relative2.query !== void 0) {
3613
- target.query = relative2.query;
3612
+ if (relative3.query !== void 0) {
3613
+ target.query = relative3.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 (relative3.path[0] === "/") {
3619
+ target.path = removeDotSegments(relative3.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 = "/" + relative3.path;
3623
3623
  } else if (!base.path) {
3624
- target.path = relative2.path;
3624
+ target.path = relative3.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) + relative3.path;
3627
3627
  }
3628
3628
  target.path = removeDotSegments(target.path);
3629
3629
  }
3630
- target.query = relative2.query;
3630
+ target.query = relative3.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 = relative3.fragment;
3639
3639
  return target;
3640
3640
  }
3641
3641
  function equal(uriA, uriB, options) {
@@ -7041,6 +7041,15 @@ var LANGUAGE_TABLE = {
7041
7041
  python: PYTHON,
7042
7042
  java: JAVA
7043
7043
  };
7044
+ function detectLanguageFromPath(filePath) {
7045
+ const lower = filePath.toLowerCase();
7046
+ for (const config of Object.values(LANGUAGE_TABLE)) {
7047
+ for (const ext of config.extensions) {
7048
+ if (lower.endsWith(ext)) return config.id;
7049
+ }
7050
+ }
7051
+ return null;
7052
+ }
7044
7053
 
7045
7054
  // src/ast/tree-sitter-engine.ts
7046
7055
  var TreeSitterEngine = class {
@@ -7235,8 +7244,8 @@ function loadConfig() {
7235
7244
  }
7236
7245
 
7237
7246
  // 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";
7247
+ import { promises as fs3, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
7248
+ import { dirname as dirname2, join as join2, resolve as resolve3 } from "node:path";
7240
7249
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7241
7250
  import Fastify from "fastify";
7242
7251
  import fastifyStatic from "@fastify/static";
@@ -7403,6 +7412,105 @@ function renderProjectScoreMarkdown(score) {
7403
7412
  ].join("\n");
7404
7413
  }
7405
7414
 
7415
+ // src/dashboard/file-detail.ts
7416
+ import { promises as fs2 } from "node:fs";
7417
+
7418
+ // src/workspace-guard.ts
7419
+ import { isAbsolute, resolve as resolve2, sep } from "node:path";
7420
+ function resolveWithinWorkspace(workspaceRoot, filePath) {
7421
+ const workspace = resolve2(workspaceRoot);
7422
+ const candidate = isAbsolute(filePath) ? resolve2(filePath) : resolve2(workspace, filePath);
7423
+ if (candidate !== workspace && !candidate.startsWith(workspace + sep)) {
7424
+ throw new Error(
7425
+ `[claude-crap] Refusing to access '${filePath}' \u2014 path escapes the workspace root`
7426
+ );
7427
+ }
7428
+ return candidate;
7429
+ }
7430
+
7431
+ // src/dashboard/file-detail.ts
7432
+ async function buildFileDetail(input) {
7433
+ const { relativePath, workspaceRoot, astEngine, sarifStore, cyclomaticMax } = input;
7434
+ const absolutePath = resolveWithinWorkspace(workspaceRoot, relativePath);
7435
+ const source = await fs2.readFile(absolutePath, "utf8");
7436
+ const sourceLines = source.split(/\r?\n/);
7437
+ if (sourceLines.length > 0 && sourceLines[sourceLines.length - 1] === "") {
7438
+ sourceLines.pop();
7439
+ }
7440
+ const physicalLoc = sourceLines.length;
7441
+ let logicalLoc = 0;
7442
+ for (const line of sourceLines) {
7443
+ if (line.trim().length > 0) logicalLoc += 1;
7444
+ }
7445
+ const language = detectLanguageFromPath(relativePath);
7446
+ let functions = [];
7447
+ if (language && astEngine) {
7448
+ try {
7449
+ const metrics = await astEngine.analyzeFile({
7450
+ filePath: absolutePath,
7451
+ language
7452
+ });
7453
+ functions = metrics.functions.map((fn) => ({
7454
+ name: fn.name,
7455
+ startLine: fn.startLine,
7456
+ endLine: fn.endLine,
7457
+ cyclomaticComplexity: fn.cyclomaticComplexity,
7458
+ lineCount: fn.lineCount
7459
+ }));
7460
+ } catch {
7461
+ }
7462
+ }
7463
+ const allFindings = sarifStore.list();
7464
+ const fileFindings = allFindings.filter(
7465
+ (f) => f.location.uri === relativePath
7466
+ );
7467
+ const findings = fileFindings.map((f) => ({
7468
+ ruleId: f.ruleId,
7469
+ level: f.level,
7470
+ message: f.message,
7471
+ sourceTool: f.sourceTool,
7472
+ startLine: f.location.startLine,
7473
+ startColumn: f.location.startColumn,
7474
+ endLine: f.location.endLine ?? f.location.startLine,
7475
+ endColumn: f.location.endColumn ?? 0,
7476
+ effortMinutes: typeof f.properties?.effortMinutes === "number" ? f.properties.effortMinutes : 0
7477
+ }));
7478
+ let errorCount = 0;
7479
+ let warningCount = 0;
7480
+ let noteCount = 0;
7481
+ let totalEffortMinutes = 0;
7482
+ for (const f of findings) {
7483
+ if (f.level === "error") errorCount += 1;
7484
+ else if (f.level === "warning") warningCount += 1;
7485
+ else if (f.level === "note") noteCount += 1;
7486
+ totalEffortMinutes += f.effortMinutes;
7487
+ }
7488
+ const complexities = functions.map((f) => f.cyclomaticComplexity);
7489
+ const maxComplexity = complexities.length > 0 ? Math.max(...complexities) : 0;
7490
+ const avgComplexity = complexities.length > 0 ? Math.round(
7491
+ complexities.reduce((a, b) => a + b, 0) / complexities.length * 100
7492
+ ) / 100 : 0;
7493
+ return {
7494
+ filePath: relativePath,
7495
+ language,
7496
+ physicalLoc,
7497
+ logicalLoc,
7498
+ cyclomaticMax,
7499
+ sourceLines,
7500
+ functions,
7501
+ findings,
7502
+ summary: {
7503
+ totalFindings: findings.length,
7504
+ errorCount,
7505
+ warningCount,
7506
+ noteCount,
7507
+ totalEffortMinutes,
7508
+ avgComplexity,
7509
+ maxComplexity
7510
+ }
7511
+ };
7512
+ }
7513
+
7406
7514
  // src/dashboard/server.ts
7407
7515
  async function startDashboard(options) {
7408
7516
  const { config, sarifStore, workspaceStatsProvider, logger: logger2 } = options;
@@ -7416,13 +7524,45 @@ async function startDashboard(options) {
7416
7524
  root: publicRoot,
7417
7525
  prefix: "/"
7418
7526
  });
7419
- fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.5" }));
7527
+ fastify.get("/api/health", async () => ({ status: "ok", server: "claude-crap", version: "0.3.7" }));
7420
7528
  fastify.get("/api/score", async () => {
7421
7529
  const stats = await workspaceStatsProvider();
7422
7530
  const score = await buildScore(config, sarifStore, stats, urlOf(fastify, config));
7423
7531
  return score;
7424
7532
  });
7425
7533
  fastify.get("/api/sarif", async () => sarifStore.toSarifDocument());
7534
+ fastify.get("/api/complexity", async () => {
7535
+ if (!options.astEngine) {
7536
+ return { threshold: config.cyclomaticMax, totalFunctions: 0, violationCount: 0, topFunctions: [] };
7537
+ }
7538
+ return buildComplexityReport(config, options.astEngine, logger2);
7539
+ });
7540
+ fastify.get("/api/file-detail", async (request, reply) => {
7541
+ const { path: filePath } = request.query;
7542
+ if (!filePath) {
7543
+ return reply.status(400).send({ error: "Missing required query parameter: path" });
7544
+ }
7545
+ try {
7546
+ const detail = await buildFileDetail({
7547
+ relativePath: filePath,
7548
+ workspaceRoot: config.pluginRoot,
7549
+ astEngine: options.astEngine,
7550
+ sarifStore,
7551
+ cyclomaticMax: config.cyclomaticMax
7552
+ });
7553
+ return detail;
7554
+ } catch (err) {
7555
+ const msg = err.message;
7556
+ if (msg.includes("ENOENT") || msg.includes("not found")) {
7557
+ return reply.status(404).send({ error: `File not found: ${filePath}` });
7558
+ }
7559
+ if (msg.includes("escapes the workspace")) {
7560
+ return reply.status(400).send({ error: msg });
7561
+ }
7562
+ logger2.error({ err: msg, filePath }, "file-detail endpoint error");
7563
+ return reply.status(500).send({ error: "Internal server error" });
7564
+ }
7565
+ });
7426
7566
  fastify.get("/", async (_request, reply) => {
7427
7567
  return reply.sendFile("index.html");
7428
7568
  });
@@ -7444,19 +7584,19 @@ async function resolvePublicRoot(logger2) {
7444
7584
  const here = dirname2(fileURLToPath2(import.meta.url));
7445
7585
  const candidates = [
7446
7586
  // 0. Bundled layout: plugin/bundle/mcp-server.mjs → ./dashboard/public
7447
- resolve2(here, "dashboard", "public"),
7587
+ resolve3(here, "dashboard", "public"),
7448
7588
  // 1. Compiled layout: dist/dashboard/server.js → ./public next to it
7449
7589
  // (only present if a build step copies the assets — not used
7450
7590
  // today, but accepted so a future copy step does not break us).
7451
- resolve2(here, "public"),
7591
+ resolve3(here, "public"),
7452
7592
  // 2. Source-relative layout: dist/dashboard/server.js → ../../src/dashboard/public
7453
7593
  // This is the default — no copy step required because we resolve
7454
7594
  // upward from `dist/` into `src/` at runtime.
7455
- resolve2(here, "..", "..", "src", "dashboard", "public")
7595
+ resolve3(here, "..", "..", "src", "dashboard", "public")
7456
7596
  ];
7457
7597
  for (const candidate of candidates) {
7458
7598
  try {
7459
- await fs2.access(resolve2(candidate, "index.html"));
7599
+ await fs3.access(resolve3(candidate, "index.html"));
7460
7600
  return candidate;
7461
7601
  } catch {
7462
7602
  }
@@ -7541,6 +7681,73 @@ async function killStaleDashboard(pidFilePath, port, logger2) {
7541
7681
  removePidFile(pidFilePath);
7542
7682
  await new Promise((r) => setTimeout(r, 300));
7543
7683
  }
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) {
7701
+ const threshold = config.cyclomaticMax;
7702
+ const allFunctions = [];
7703
+ let totalFunctions = 0;
7704
+ async function walk2(dir) {
7705
+ let entries;
7706
+ try {
7707
+ entries = await fs3.readdir(dir, { withFileTypes: true });
7708
+ } catch {
7709
+ return;
7710
+ }
7711
+ for (const entry of entries) {
7712
+ if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
7713
+ const full = join2(dir, entry.name);
7714
+ if (entry.isDirectory()) {
7715
+ if (SKIP_DIRS.has(entry.name)) continue;
7716
+ await walk2(full);
7717
+ continue;
7718
+ }
7719
+ if (!entry.isFile()) continue;
7720
+ const language = detectLanguageFromPath(entry.name);
7721
+ if (!language) continue;
7722
+ try {
7723
+ const metrics = await engine.analyzeFile({ filePath: full, language });
7724
+ for (const fn of metrics.functions) {
7725
+ totalFunctions += 1;
7726
+ allFunctions.push({
7727
+ filePath: full.startsWith(config.pluginRoot) ? full.substring(config.pluginRoot.length + 1) : full,
7728
+ name: fn.name,
7729
+ cyclomaticComplexity: fn.cyclomaticComplexity,
7730
+ startLine: fn.startLine,
7731
+ endLine: fn.endLine,
7732
+ lineCount: fn.lineCount
7733
+ });
7734
+ }
7735
+ } catch (err) {
7736
+ logger2.warn(
7737
+ { filePath: full, err: err.message },
7738
+ "complexity-report: failed to analyze file"
7739
+ );
7740
+ }
7741
+ }
7742
+ }
7743
+ await walk2(config.pluginRoot);
7744
+ allFunctions.sort((a, b) => b.cyclomaticComplexity - a.cyclomaticComplexity);
7745
+ const topFunctions = allFunctions.slice(0, 20);
7746
+ const violationCount = allFunctions.filter(
7747
+ (f) => f.cyclomaticComplexity > threshold
7748
+ ).length;
7749
+ return { threshold, totalFunctions, violationCount, topFunctions };
7750
+ }
7544
7751
  async function buildScore(config, sarifStore, workspace, dashboardUrl) {
7545
7752
  return computeProjectScore({
7546
7753
  workspaceRoot: config.pluginRoot,
@@ -7583,9 +7790,9 @@ function computeCrap(input, threshold) {
7583
7790
  }
7584
7791
 
7585
7792
  // src/metrics/workspace-walker.ts
7586
- import { promises as fs3 } from "node:fs";
7793
+ import { promises as fs4 } from "node:fs";
7587
7794
  import { join as join3 } from "node:path";
7588
- var SKIP_DIRS = /* @__PURE__ */ new Set([
7795
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([
7589
7796
  "node_modules",
7590
7797
  ".git",
7591
7798
  "dist",
@@ -7632,7 +7839,7 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7632
7839
  if (truncated) return;
7633
7840
  let entries;
7634
7841
  try {
7635
- entries = await fs3.readdir(dir, { withFileTypes: true });
7842
+ entries = await fs4.readdir(dir, { withFileTypes: true });
7636
7843
  } catch {
7637
7844
  return;
7638
7845
  }
@@ -7641,7 +7848,7 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7641
7848
  if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
7642
7849
  const full = join3(dir, entry.name);
7643
7850
  if (entry.isDirectory()) {
7644
- if (SKIP_DIRS.has(entry.name)) continue;
7851
+ if (SKIP_DIRS2.has(entry.name)) continue;
7645
7852
  await walk2(full);
7646
7853
  continue;
7647
7854
  }
@@ -7657,7 +7864,7 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7657
7864
  return;
7658
7865
  }
7659
7866
  try {
7660
- const content = await fs3.readFile(full, "utf8");
7867
+ const content = await fs4.readFile(full, "utf8");
7661
7868
  if (content.length > 0) {
7662
7869
  const lines = content.split(/\r?\n/).length;
7663
7870
  physicalLoc += content.endsWith("\n") ? lines - 1 : lines;
@@ -7671,8 +7878,8 @@ async function estimateWorkspaceLoc(workspaceRoot) {
7671
7878
  }
7672
7879
 
7673
7880
  // 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";
7881
+ import { promises as fs5 } from "node:fs";
7882
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, resolve as resolve4 } from "node:path";
7676
7883
 
7677
7884
  // src/sarif/sarif-builder.ts
7678
7885
  function buildSarifDocument(tool, findings) {
@@ -7734,7 +7941,7 @@ var SarifStore = class {
7734
7941
  /** Tool invocations we have already ingested, for telemetry. */
7735
7942
  toolInvocations = 0;
7736
7943
  constructor(options) {
7737
- const dir = isAbsolute(options.outputDir) ? options.outputDir : resolve3(options.workspaceRoot, options.outputDir);
7944
+ const dir = isAbsolute2(options.outputDir) ? options.outputDir : resolve4(options.workspaceRoot, options.outputDir);
7738
7945
  this.filePath = join4(dir, options.fileName ?? "latest.sarif");
7739
7946
  }
7740
7947
  /**
@@ -7759,7 +7966,7 @@ var SarifStore = class {
7759
7966
  */
7760
7967
  async loadLatest() {
7761
7968
  try {
7762
- const raw = await fs4.readFile(this.filePath, "utf8");
7969
+ const raw = await fs5.readFile(this.filePath, "utf8");
7763
7970
  const parsed = JSON.parse(raw);
7764
7971
  if (parsed.version !== "2.1.0") {
7765
7972
  throw new Error(`Expected SARIF 2.1.0, got ${parsed.version}`);
@@ -7861,10 +8068,10 @@ var SarifStore = class {
7861
8068
  */
7862
8069
  async persist() {
7863
8070
  const doc = this.toSarifDocument();
7864
- await fs4.mkdir(dirname3(this.filePath), { recursive: true });
8071
+ await fs5.mkdir(dirname3(this.filePath), { recursive: true });
7865
8072
  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);
8073
+ await fs5.writeFile(tmp, JSON.stringify(doc, null, 2), "utf8");
8074
+ await fs5.rename(tmp, this.filePath);
7868
8075
  }
7869
8076
  /**
7870
8077
  * Build the current consolidated SARIF document from the in-memory
@@ -8080,22 +8287,22 @@ function isStrictness(value) {
8080
8287
  }
8081
8288
 
8082
8289
  // 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";
8290
+ 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";
8085
8292
  var TEST_SUFFIX_PATTERN = /\.(test|spec)\./;
8086
8293
  function isTestFile(filePath) {
8087
8294
  const base = basename(filePath);
8088
8295
  if (TEST_SUFFIX_PATTERN.test(base)) return true;
8089
8296
  if (base.startsWith("test_") && base.endsWith(".py")) return true;
8090
- const parts = filePath.split(sep);
8297
+ const parts = filePath.split(sep2);
8091
8298
  return parts.includes("__tests__") || parts.includes("tests") || parts.includes("test");
8092
8299
  }
8093
8300
  function candidatePaths(workspaceRoot, filePath) {
8094
- const absSource = resolve4(filePath);
8301
+ const absSource = resolve5(filePath);
8095
8302
  const ext = extname(absSource);
8096
8303
  const base = basename(absSource, ext);
8097
8304
  const dir = dirname4(absSource);
8098
- const absWorkspace = resolve4(workspaceRoot);
8305
+ const absWorkspace = resolve5(workspaceRoot);
8099
8306
  const relFromRoot = relative(absWorkspace, absSource);
8100
8307
  const relDir = dirname4(relFromRoot);
8101
8308
  const candidates = /* @__PURE__ */ new Set();
@@ -8128,14 +8335,14 @@ function candidatePaths(workspaceRoot, filePath) {
8128
8335
  return Array.from(candidates);
8129
8336
  }
8130
8337
  async function findTestFile(workspaceRoot, filePath) {
8131
- const absolute = isAbsolute2(filePath) ? filePath : resolve4(workspaceRoot, filePath);
8338
+ const absolute = isAbsolute3(filePath) ? filePath : resolve5(workspaceRoot, filePath);
8132
8339
  if (isTestFile(absolute)) {
8133
8340
  return { testFile: absolute, candidates: [absolute], isTestFile: true };
8134
8341
  }
8135
8342
  const candidates = candidatePaths(workspaceRoot, absolute);
8136
8343
  for (const candidate of candidates) {
8137
8344
  try {
8138
- await fs5.access(candidate);
8345
+ await fs6.access(candidate);
8139
8346
  return { testFile: candidate, candidates, isTestFile: false };
8140
8347
  } catch {
8141
8348
  }
@@ -8143,22 +8350,9 @@ async function findTestFile(workspaceRoot, filePath) {
8143
8350
  return { testFile: null, candidates, isTestFile: false };
8144
8351
  }
8145
8352
 
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
8353
  // src/scanner/auto-scan.ts
8160
8354
  import { existsSync as existsSync5 } from "node:fs";
8161
- import { join as join10 } from "node:path";
8355
+ import { join as join11 } from "node:path";
8162
8356
 
8163
8357
  // src/scanner/detector.ts
8164
8358
  import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
@@ -8700,6 +8894,135 @@ function buildResult(projectType, steps, autoScanResult, recommendation) {
8700
8894
  };
8701
8895
  }
8702
8896
 
8897
+ // src/scanner/complexity-scanner.ts
8898
+ 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
+ ]);
8916
+ var MAX_FILES = 2e4;
8917
+ var RULE_ID = "complexity/cyclomatic-max";
8918
+ var SOURCE_TOOL = "complexity";
8919
+ async function scanComplexity(workspaceRoot, engine, sarifStore, config, logger2) {
8920
+ const start = Date.now();
8921
+ const threshold = config.cyclomaticMax;
8922
+ const errorThreshold = threshold * 2;
8923
+ const files = await collectSourceFiles(workspaceRoot);
8924
+ logger2.info(
8925
+ { fileCount: files.length, threshold },
8926
+ "complexity-scanner: starting analysis"
8927
+ );
8928
+ const sarifResults = [];
8929
+ let filesScanned = 0;
8930
+ let functionsAnalyzed = 0;
8931
+ let violations = 0;
8932
+ for (const filePath of files) {
8933
+ const language = detectLanguageFromPath(filePath);
8934
+ if (!language) continue;
8935
+ try {
8936
+ const metrics = await engine.analyzeFile({ filePath, language });
8937
+ filesScanned += 1;
8938
+ functionsAnalyzed += metrics.functions.length;
8939
+ for (const fn of metrics.functions) {
8940
+ if (fn.cyclomaticComplexity <= threshold) continue;
8941
+ const level = fn.cyclomaticComplexity >= errorThreshold ? "error" : "warning";
8942
+ const relPath = relative2(workspaceRoot, filePath);
8943
+ sarifResults.push({
8944
+ ruleId: RULE_ID,
8945
+ level,
8946
+ message: {
8947
+ text: `Function '${fn.name}' has cyclomatic complexity ${fn.cyclomaticComplexity} (threshold: ${threshold})`
8948
+ },
8949
+ locations: [
8950
+ {
8951
+ physicalLocation: {
8952
+ artifactLocation: { uri: relPath },
8953
+ region: {
8954
+ startLine: fn.startLine,
8955
+ startColumn: 1,
8956
+ endLine: fn.endLine,
8957
+ endColumn: 1
8958
+ }
8959
+ }
8960
+ }
8961
+ ],
8962
+ properties: {
8963
+ sourceTool: SOURCE_TOOL,
8964
+ effortMinutes: estimateEffortMinutes(level),
8965
+ cyclomaticComplexity: fn.cyclomaticComplexity
8966
+ }
8967
+ });
8968
+ violations += 1;
8969
+ }
8970
+ } catch (err) {
8971
+ logger2.warn(
8972
+ { filePath, err: err.message },
8973
+ "complexity-scanner: failed to analyze file, skipping"
8974
+ );
8975
+ }
8976
+ }
8977
+ if (sarifResults.length > 0) {
8978
+ const document = wrapResultsInSarif(
8979
+ SOURCE_TOOL,
8980
+ "0.1.0",
8981
+ sarifResults
8982
+ );
8983
+ sarifStore.ingestRun(document, SOURCE_TOOL);
8984
+ await sarifStore.persist();
8985
+ }
8986
+ const durationMs = Date.now() - start;
8987
+ logger2.info(
8988
+ { filesScanned, functionsAnalyzed, violations, durationMs },
8989
+ "complexity-scanner: analysis complete"
8990
+ );
8991
+ return { filesScanned, functionsAnalyzed, violations, durationMs };
8992
+ }
8993
+ async function collectSourceFiles(workspaceRoot) {
8994
+ const files = [];
8995
+ let truncated = false;
8996
+ async function walk2(dir) {
8997
+ if (truncated) return;
8998
+ let entries;
8999
+ try {
9000
+ entries = await fs7.readdir(dir, { withFileTypes: true });
9001
+ } catch {
9002
+ return;
9003
+ }
9004
+ for (const entry of entries) {
9005
+ if (truncated) return;
9006
+ if (entry.name.startsWith(".") && entry.name !== ".claude-plugin") continue;
9007
+ const full = join10(dir, entry.name);
9008
+ if (entry.isDirectory()) {
9009
+ if (SKIP_DIRS3.has(entry.name)) continue;
9010
+ await walk2(full);
9011
+ continue;
9012
+ }
9013
+ if (!entry.isFile()) continue;
9014
+ if (!detectLanguageFromPath(entry.name)) continue;
9015
+ files.push(full);
9016
+ if (files.length >= MAX_FILES) {
9017
+ truncated = true;
9018
+ return;
9019
+ }
9020
+ }
9021
+ }
9022
+ await walk2(workspaceRoot);
9023
+ return files;
9024
+ }
9025
+
8703
9026
  // src/scanner/auto-scan.ts
8704
9027
  function ingestScannerRun(scanner, rawOutput, sarifStore) {
8705
9028
  let parsed;
@@ -8712,7 +9035,7 @@ function ingestScannerRun(scanner, rawOutput, sarifStore) {
8712
9035
  const stats = sarifStore.ingestRun(adapted.document, adapted.sourceTool);
8713
9036
  return { accepted: stats.accepted };
8714
9037
  }
8715
- async function autoScan(workspaceRoot, sarifStore, logger2) {
9038
+ async function autoScan(workspaceRoot, sarifStore, logger2, options) {
8716
9039
  const start = Date.now();
8717
9040
  const detected = await detectScanners(workspaceRoot);
8718
9041
  const available = detected.filter((d) => d.available);
@@ -8737,7 +9060,7 @@ async function autoScan(workspaceRoot, sarifStore, logger2) {
8737
9060
  ".eslintrc.json"
8738
9061
  ];
8739
9062
  const eslintDetected = available.some((d) => d.scanner === "eslint");
8740
- const hasEslintConfig = eslintConfigFiles.some((f) => existsSync5(join10(workspaceRoot, f)));
9063
+ const hasEslintConfig = eslintConfigFiles.some((f) => existsSync5(join11(workspaceRoot, f)));
8741
9064
  if (eslintDetected && !hasEslintConfig) {
8742
9065
  logger2.info("auto-scan: ESLint detected but no config \u2014 running bootstrap");
8743
9066
  try {
@@ -8847,11 +9170,30 @@ async function autoScan(workspaceRoot, sarifStore, logger2) {
8847
9170
  if (persistNeeded) {
8848
9171
  await sarifStore.persist();
8849
9172
  }
9173
+ let complexityScan;
9174
+ if (options?.engine) {
9175
+ try {
9176
+ complexityScan = await scanComplexity(
9177
+ workspaceRoot,
9178
+ options.engine,
9179
+ sarifStore,
9180
+ { cyclomaticMax: options.cyclomaticMax ?? 15 },
9181
+ logger2
9182
+ );
9183
+ totalFindings += complexityScan.violations;
9184
+ } catch (err) {
9185
+ logger2.warn(
9186
+ { err: err.message },
9187
+ "auto-scan: complexity scanner failed \u2014 continuing without it"
9188
+ );
9189
+ }
9190
+ }
8850
9191
  return {
8851
9192
  detected,
8852
9193
  results,
8853
9194
  totalFindings,
8854
- totalDurationMs: Date.now() - start
9195
+ totalDurationMs: Date.now() - start,
9196
+ ...complexityScan ? { complexityScan } : {}
8855
9197
  };
8856
9198
  }
8857
9199
 
@@ -9047,7 +9389,8 @@ async function main() {
9047
9389
  config,
9048
9390
  sarifStore,
9049
9391
  workspaceStatsProvider: () => estimateWorkspaceLoc(config.pluginRoot),
9050
- logger
9392
+ logger,
9393
+ astEngine
9051
9394
  });
9052
9395
  } catch (err) {
9053
9396
  logger.warn(
@@ -9438,7 +9781,10 @@ async function main() {
9438
9781
  case "auto_scan": {
9439
9782
  logger.info({ tool: "auto_scan" }, "Tool call received");
9440
9783
  try {
9441
- const result = await autoScan(config.pluginRoot, sarifStore, logger);
9784
+ const result = await autoScan(config.pluginRoot, sarifStore, logger, {
9785
+ engine: astEngine,
9786
+ cyclomaticMax: config.cyclomaticMax
9787
+ });
9442
9788
  const markdown = renderAutoScanMarkdown(result);
9443
9789
  return {
9444
9790
  content: [
@@ -9514,7 +9860,10 @@ async function main() {
9514
9860
  const transport = new StdioServerTransport();
9515
9861
  await server.connect(transport);
9516
9862
  logger.info("claude-crap MCP server ready (stdio)");
9517
- autoScan(config.pluginRoot, sarifStore, logger).then((result) => {
9863
+ autoScan(config.pluginRoot, sarifStore, logger, {
9864
+ engine: astEngine,
9865
+ cyclomaticMax: config.cyclomaticMax
9866
+ }).then((result) => {
9518
9867
  const scanners = result.results.filter((r) => r.success).map((r) => r.scanner);
9519
9868
  logger.info(
9520
9869
  {
@@ -9581,6 +9930,15 @@ function renderAutoScanMarkdown(result) {
9581
9930
  }
9582
9931
  lines.push("");
9583
9932
  }
9933
+ if (result.complexityScan) {
9934
+ const cs = result.complexityScan;
9935
+ lines.push("### Cyclomatic complexity scan\n");
9936
+ lines.push(`- Files scanned: **${cs.filesScanned}**`);
9937
+ lines.push(`- Functions analyzed: **${cs.functionsAnalyzed}**`);
9938
+ lines.push(`- Violations: **${cs.violations}**`);
9939
+ lines.push(`- Duration: ${(cs.durationMs / 1e3).toFixed(1)}s`);
9940
+ lines.push("");
9941
+ }
9584
9942
  lines.push(
9585
9943
  `**Total findings ingested:** ${result.totalFindings} in ${(result.totalDurationMs / 1e3).toFixed(1)}s`
9586
9944
  );