secufusion-mcp 1.0.59 → 1.1.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 (2) hide show
  1. package/index.js +163 -29
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -511,8 +511,8 @@ function resolveTaskFolder(workItemId, tasksRoot, registryData) {
511
511
  return null;
512
512
  }
513
513
  function generateNewTaskFolder(workItemId, title, tasksRoot) {
514
- // Strip potential leading ID prefix like "BUG-1173: " or "BUG-1173 " or "WI-1173: "
515
- const strippedTitle = title.replace(/^[a-zA-Z]+-\d+[:\s]*/i, '');
514
+ // Strip potential leading ID prefix from the title (e.g. "BUG-1173: " or "1173: ")
515
+ const strippedTitle = title.replace(/^[a-zA-Z]*[-]?\d+[:\s]*/i, '');
516
516
  const slug = strippedTitle.toLowerCase()
517
517
  .replace(/[:\s]+/g, '-')
518
518
  .replace(/[^a-z0-9-]+/g, '')
@@ -520,7 +520,31 @@ function generateNewTaskFolder(workItemId, title, tasksRoot) {
520
520
  .replace(/^-|-$/g, '')
521
521
  .slice(0, 50)
522
522
  .replace(/-+$/, '');
523
- return path.join(tasksRoot, `${workItemId}-${slug}`);
523
+ // Determine standard prefix (e.g., bug-1173, story-1097)
524
+ let numericId = workItemId.replace(/\D/g, ''); // Extract just the digits
525
+ if (!numericId)
526
+ numericId = workItemId; // fallback
527
+ let taskTypeStr = "task";
528
+ // Try to load the classification to get the true task_type
529
+ const classifPath = path.join(tasksRoot, '..', 'classifications', `${workItemId}.json`);
530
+ const classif = readJson(classifPath);
531
+ if (classif && typeof classif.task_type === 'string') {
532
+ const rawType = classif.task_type.toLowerCase();
533
+ if (rawType === 'user_story')
534
+ taskTypeStr = 'story';
535
+ else
536
+ taskTypeStr = rawType;
537
+ }
538
+ else {
539
+ // Fallback: guess from the workItemId string itself if passed as "BUG-1173"
540
+ const prefixMatch = workItemId.match(/^([a-zA-Z]+)-/);
541
+ if (prefixMatch) {
542
+ taskTypeStr = prefixMatch[1].toLowerCase();
543
+ if (taskTypeStr === 'wi')
544
+ taskTypeStr = 'task'; // Generic WI to task
545
+ }
546
+ }
547
+ return path.join(tasksRoot, `${taskTypeStr}-${numericId}-${slug}`);
524
548
  }
525
549
  function readJson(filePath) {
526
550
  const raw = readFileSafe(filePath);
@@ -2770,6 +2794,7 @@ server.tool("run_pre_pr_checks_with_reviewer_agent", "Unified, single-call PR re
2770
2794
  // ══════════════════════════════════════════════════════════════════════
2771
2795
  // ── P1: TENANT_ISOLATION ──────────────────────────────────────────────
2772
2796
  const p1 = [];
2797
+ const p1Suppressed = [];
2773
2798
  if (!skip_checks.includes("tenant_isolation")) {
2774
2799
  for (const f of fileRecords.filter(f => f.isRepo)) {
2775
2800
  const queryAnnotationLines = [];
@@ -2780,23 +2805,69 @@ server.tool("run_pre_pr_checks_with_reviewer_agent", "Unified, single-call PR re
2780
2805
  f.lines.forEach((line, idx) => {
2781
2806
  const dm = line.match(/(?:find|delete|count|exists)\w+By(\w+)/);
2782
2807
  if (dm && !dm[1].toLowerCase().includes("tenant")) {
2783
- p1.push({ check: "TENANT_ISOLATION", severity: "BLOCKING", file: f.file_path, line: idx + 1,
2784
- message: `Derived query \`${line.trim()}\` does not filter by tenantId`,
2785
- suggestion: "Rename to include AndTenantId, e.g. findByIdAndTenantId(UUID id, UUID tenantId)" });
2808
+ let suppressionReason = "";
2809
+ for (let i = Math.max(0, idx - 5); i < idx; i++) {
2810
+ const match = f.lines[i].match(/@TenantScopeException\s*\(\s*"([^"]+)"\s*\)/i);
2811
+ if (match)
2812
+ suppressionReason = match[1];
2813
+ }
2814
+ if (suppressionReason) {
2815
+ p1Suppressed.push({ file: f.file_path, line: idx + 1, reason: suppressionReason, method: dm[0] });
2816
+ }
2817
+ else {
2818
+ p1.push({ check: "TENANT_ISOLATION", severity: "BLOCKING", file: f.file_path, line: idx + 1,
2819
+ message: `Derived query \`${line.trim()}\` does not filter by tenantId`,
2820
+ suggestion: "Rename to include AndTenantId, e.g. findByIdAndTenantId(UUID id, UUID tenantId)" });
2821
+ }
2786
2822
  }
2787
2823
  });
2788
2824
  queryAnnotationLines.forEach(ai => {
2789
2825
  const block = f.lines.slice(ai, ai + 5).join(" ");
2790
2826
  if (!/tenantId|tenant_id|:tenantId/i.test(block)) {
2791
- p1.push({ check: "TENANT_ISOLATION", severity: "BLOCKING", file: f.file_path, line: ai + 1,
2792
- message: `@Query at line ${ai + 1} has no tenantId filter`,
2793
- suggestion: "Add WHERE e.tenantId = :tenantId to the JPQL/SQL query" });
2827
+ let suppressionReason = "";
2828
+ for (let i = Math.max(0, ai - 5); i < ai; i++) {
2829
+ const match = f.lines[i].match(/@TenantScopeException\s*\(\s*"([^"]+)"\s*\)/i);
2830
+ if (match)
2831
+ suppressionReason = match[1];
2832
+ }
2833
+ if (suppressionReason) {
2834
+ p1Suppressed.push({ file: f.file_path, line: ai + 1, reason: suppressionReason, method: "@Query method" });
2835
+ }
2836
+ else {
2837
+ p1.push({ check: "TENANT_ISOLATION", severity: "BLOCKING", file: f.file_path, line: ai + 1,
2838
+ message: `@Query at line ${ai + 1} has no tenantId filter`,
2839
+ suggestion: "Add WHERE e.tenantId = :tenantId to the JPQL/SQL query" });
2840
+ }
2794
2841
  }
2795
2842
  });
2796
2843
  }
2797
2844
  }
2798
2845
  mechanical.push(...p1);
2799
- passResults.push({ name: "TENANT_ISOLATION", skipped: skip_checks.includes("tenant_isolation"), passed: p1.length === 0, count: p1.length });
2846
+ passResults.push({ name: "TENANT_ISOLATION", skipped: skip_checks.includes("tenant_isolation"), passed: p1.length === 0, count: p1.length, suppressedCount: p1Suppressed.length });
2847
+ // Auto-log suppressions to decisions.json
2848
+ if (p1Suppressed.length > 0) {
2849
+ const tasksRoot = resolve(TASKS_DIR);
2850
+ const dir = resolveTaskFolder(work_item_id, tasksRoot);
2851
+ if (dir) {
2852
+ const decisionsPath = path.join(dir, "decisions.json");
2853
+ const decisions = readJson(decisionsPath) || [];
2854
+ let modified = false;
2855
+ for (const sup of p1Suppressed) {
2856
+ const desc = `Exempted TENANT_ISOLATION on ${sup.method}`;
2857
+ if (!decisions.some(d => d.decision === desc)) {
2858
+ decisions.push({
2859
+ id: decisions.length + 1,
2860
+ decision: desc,
2861
+ rationale: sup.reason,
2862
+ logged_at: new Date().toISOString()
2863
+ });
2864
+ modified = true;
2865
+ }
2866
+ }
2867
+ if (modified)
2868
+ writeFile(decisionsPath, JSON.stringify(decisions, null, 2));
2869
+ }
2870
+ }
2800
2871
  // ── P2: N_PLUS_ONE ────────────────────────────────────────────────────
2801
2872
  const p2 = [];
2802
2873
  if (!skip_checks.includes("n_plus_one")) {
@@ -3254,7 +3325,8 @@ server.tool("run_pre_pr_checks_with_reviewer_agent", "Unified, single-call PR re
3254
3325
  for (let i = 0; i < passResults.length; i++) {
3255
3326
  const p = passResults[i];
3256
3327
  const icon = p.skipped ? "⏭️ SKIP" : p.passed ? "✅ PASS" : "❌ FAIL";
3257
- out += `| P${i + 1} | ${p.name} | ${icon} | ${p.skipped ? "—" : p.count} |\n`;
3328
+ const issueText = p.skipped ? "—" : (p.suppressedCount ? `${p.count} (${p.suppressedCount} suppressed)` : `${p.count}`);
3329
+ out += `| P${i + 1} | ${p.name} | ${icon} | ${issueText} |\n`;
3258
3330
  }
3259
3331
  if (blockingMechanical.length > 0) {
3260
3332
  out += `\n#### ❌ Blocking Mechanical Findings\n`;
@@ -3263,6 +3335,13 @@ server.tool("run_pre_pr_checks_with_reviewer_agent", "Unified, single-call PR re
3263
3335
  out += `> ${f.message}\n> 💡 *${f.suggestion}*\n`;
3264
3336
  }
3265
3337
  }
3338
+ if (p1Suppressed.length > 0) {
3339
+ out += `\n#### 🛡️ Suppressed Findings (${p1Suppressed.length})\n`;
3340
+ for (const sup of p1Suppressed) {
3341
+ out += `\n**[TENANT_ISOLATION]** \`${sup.file}\` line ${sup.line}\n`;
3342
+ out += `> Suppressed — ${sup.reason}\n`;
3343
+ }
3344
+ }
3266
3345
  const warnings1 = mechanical.filter(f => f.severity === "WARNING");
3267
3346
  if (warnings1.length > 0) {
3268
3347
  out += `\n#### ⚠️ Warnings\n`;
@@ -3463,6 +3542,7 @@ server.tool("run_pre_pr_checks", "Full-spectrum PR review system — runs BEFORE
3463
3542
  // Rule: every Repository method must filter by tenantId
3464
3543
  // ══════════════════════════════════════════════════════════════════════
3465
3544
  const p1Findings = [];
3545
+ const p1Suppressed = [];
3466
3546
  if (!skip_checks.includes("tenant_isolation")) {
3467
3547
  for (const f of fileRecords.filter(f => f.isRepo)) {
3468
3548
  // Find method signatures in repository interface/class
@@ -3478,14 +3558,25 @@ server.tool("run_pre_pr_checks", "Full-spectrum PR review system — runs BEFORE
3478
3558
  if (derivedMatch) {
3479
3559
  const byClause = derivedMatch[1].toLowerCase();
3480
3560
  if (!byClause.includes("tenantid") && !byClause.includes("tenant")) {
3481
- p1Findings.push({
3482
- check: "TENANT_ISOLATION",
3483
- severity: "BLOCKING",
3484
- file: f.file_path,
3485
- line: idx + 1,
3486
- message: `Derived query \`${line.trim()}\` does not filter by tenantId`,
3487
- suggestion: "Rename to include AndTenantId: e.g. findByIdAndTenantId(UUID id, UUID tenantId)",
3488
- });
3561
+ let suppressionReason = "";
3562
+ for (let i = Math.max(0, idx - 5); i < idx; i++) {
3563
+ const match = f.lines[i].match(/@TenantScopeException\s*\(\s*"([^"]+)"\s*\)/i);
3564
+ if (match)
3565
+ suppressionReason = match[1];
3566
+ }
3567
+ if (suppressionReason) {
3568
+ p1Suppressed.push({ file: f.file_path, line: idx + 1, reason: suppressionReason, method: derivedMatch[0] });
3569
+ }
3570
+ else {
3571
+ p1Findings.push({
3572
+ check: "TENANT_ISOLATION",
3573
+ severity: "BLOCKING",
3574
+ file: f.file_path,
3575
+ line: idx + 1,
3576
+ message: `Derived query \`${line.trim()}\` does not filter by tenantId`,
3577
+ suggestion: "Rename to include AndTenantId: e.g. findByIdAndTenantId(UUID id, UUID tenantId)",
3578
+ });
3579
+ }
3489
3580
  }
3490
3581
  }
3491
3582
  });
@@ -3494,20 +3585,55 @@ server.tool("run_pre_pr_checks", "Full-spectrum PR review system — runs BEFORE
3494
3585
  // Look at this line and next 3 lines for the query body
3495
3586
  const queryBlock = f.lines.slice(annotIdx, annotIdx + 5).join(" ");
3496
3587
  if (!/tenantId|tenant_id|:tenantId/i.test(queryBlock)) {
3497
- p1Findings.push({
3498
- check: "TENANT_ISOLATION",
3499
- severity: "BLOCKING",
3500
- file: f.file_path,
3501
- line: annotIdx + 1,
3502
- message: `@Query at line ${annotIdx + 1} does not include tenantId filter`,
3503
- suggestion: "Add WHERE e.tenantId = :tenantId to the JPQL/SQL query",
3504
- });
3588
+ let suppressionReason = "";
3589
+ for (let i = Math.max(0, annotIdx - 5); i < annotIdx; i++) {
3590
+ const match = f.lines[i].match(/@TenantScopeException\s*\(\s*"([^"]+)"\s*\)/i);
3591
+ if (match)
3592
+ suppressionReason = match[1];
3593
+ }
3594
+ if (suppressionReason) {
3595
+ p1Suppressed.push({ file: f.file_path, line: annotIdx + 1, reason: suppressionReason, method: "@Query method" });
3596
+ }
3597
+ else {
3598
+ p1Findings.push({
3599
+ check: "TENANT_ISOLATION",
3600
+ severity: "BLOCKING",
3601
+ file: f.file_path,
3602
+ line: annotIdx + 1,
3603
+ message: `@Query at line ${annotIdx + 1} does not include tenantId filter`,
3604
+ suggestion: "Add WHERE e.tenantId = :tenantId to the JPQL/SQL query",
3605
+ });
3606
+ }
3505
3607
  }
3506
3608
  });
3507
3609
  }
3508
3610
  }
3509
3611
  allFindings.push(...p1Findings);
3510
- passResults.push({ name: "TENANT_ISOLATION", skipped: skip_checks.includes("tenant_isolation"), passed: p1Findings.length === 0, count: p1Findings.length });
3612
+ passResults.push({ name: "TENANT_ISOLATION", skipped: skip_checks.includes("tenant_isolation"), passed: p1Findings.length === 0, count: p1Findings.length, suppressedCount: p1Suppressed.length });
3613
+ // Auto-log suppressions to decisions.json
3614
+ if (p1Suppressed.length > 0 && work_item_id) {
3615
+ const tasksRoot = resolve(TASKS_DIR);
3616
+ const dir = resolveTaskFolder(work_item_id, tasksRoot);
3617
+ if (dir) {
3618
+ const decisionsPath = path.join(dir, "decisions.json");
3619
+ const decisions = readJson(decisionsPath) || [];
3620
+ let modified = false;
3621
+ for (const sup of p1Suppressed) {
3622
+ const desc = `Exempted TENANT_ISOLATION on ${sup.method}`;
3623
+ if (!decisions.some(d => d.decision === desc)) {
3624
+ decisions.push({
3625
+ id: decisions.length + 1,
3626
+ decision: desc,
3627
+ rationale: sup.reason,
3628
+ logged_at: new Date().toISOString()
3629
+ });
3630
+ modified = true;
3631
+ }
3632
+ }
3633
+ if (modified)
3634
+ writeFile(decisionsPath, JSON.stringify(decisions, null, 2));
3635
+ }
3636
+ }
3511
3637
  // ══════════════════════════════════════════════════════════════════════
3512
3638
  // PASS 2 — N_PLUS_ONE
3513
3639
  // Rule: no repository method calls inside for/forEach loops
@@ -3934,7 +4060,8 @@ server.tool("run_pre_pr_checks", "Full-spectrum PR review system — runs BEFORE
3934
4060
  out += `| Pass | Check | Status | Findings |\n|---|---|---|---|\n`;
3935
4061
  for (const p of passResults) {
3936
4062
  const statusIcon = p.skipped ? "⏭️ SKIPPED" : p.passed ? "✅ PASS" : "❌ FAIL";
3937
- out += `| P${passResults.indexOf(p) + 1} | ${p.name} | ${statusIcon} | ${p.skipped ? "—" : p.count} |\n`;
4063
+ const issueText = p.skipped ? "—" : (p.suppressedCount ? `${p.count} (${p.suppressedCount} suppressed)` : `${p.count}`);
4064
+ out += `| P${passResults.indexOf(p) + 1} | ${p.name} | ${statusIcon} | ${issueText} |\n`;
3938
4065
  }
3939
4066
  // ── Blocking findings detail ──────────────────────────────────────────
3940
4067
  if (blockingFindings.length > 0) {
@@ -3945,6 +4072,13 @@ server.tool("run_pre_pr_checks", "Full-spectrum PR review system — runs BEFORE
3945
4072
  out += `> 💡 *${f.suggestion}*\n`;
3946
4073
  }
3947
4074
  }
4075
+ if (p1Suppressed.length > 0) {
4076
+ out += `\n### 🛡️ Suppressed Findings (${p1Suppressed.length})\n`;
4077
+ for (const sup of p1Suppressed) {
4078
+ out += `\n**[TENANT_ISOLATION]** \`${sup.file}\` line ${sup.line}\n`;
4079
+ out += `> Suppressed — ${sup.reason}\n`;
4080
+ }
4081
+ }
3948
4082
  // ── Warning findings detail ───────────────────────────────────────────
3949
4083
  const warnings = allFindings.filter(f => f.severity === "WARNING");
3950
4084
  if (warnings.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secufusion-mcp",
3
- "version": "1.0.59",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "SecuFusion MCP server - developer workflow tooling with guardrails",
6
6
  "main": "index.js",