secufusion-mcp 2.1.6 → 2.2.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.
@@ -6,13 +6,13 @@
6
6
  * Provides developer-workflow tooling for the SecuFusion platform:
7
7
  * - manage_feature_spec : create / update task blueprints (.current-task-spec.md)
8
8
  * - log_rejected_pattern : record bad patterns so they are never repeated (.rejected-patterns.json)
9
- * - run_pre_pr_checks : gate PRs via AST-level linters (ESLint / Maven Checkstyle) + structural checks
9
+ * - /sfn:review : AI-driven reviewer that validates architecture, patterns, and guardrails
10
10
  *
11
11
  * Guardrails enforced:
12
12
  * 1. TENANT SAFETY AUTOMATED: Active scanning of *Repository.java for tenantId scoping.
13
13
  * 2. Code hygiene (console.log, unused vars, formatting) enforced via ESLint / Checkstyle — not regex.
14
14
  * 3. No hardcoded UAT/Prod IPs or environment URLs.
15
- * 4. Every modified JPA @Entity must have a matching Flyway SQL migration.
15
+ * 4. Every modified JPA @Entity must have a matching manual migration script (applied against PostgreSQL before deployment).
16
16
  */
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -49,18 +49,16 @@ function getWorkspaceRoot() {
49
49
  const rootMarkers = [".git", "package.json", "pom.xml", ".secufusion-project-spec.json", ".agents"];
50
50
  for (let i = 0; i < 10; i++) {
51
51
  for (const marker of rootMarkers) {
52
- if (fs.existsSync(path.join(currentDir, marker))) {
52
+ if (fs.existsSync(path.join(currentDir, marker)))
53
53
  return currentDir;
54
- }
55
54
  }
56
55
  const parent = path.dirname(currentDir);
57
56
  if (parent === currentDir)
58
- break; // Reached filesystem root
57
+ break;
59
58
  currentDir = parent;
60
59
  }
61
- return ROOT; // Fallback to ROOT directory
60
+ return ROOT;
62
61
  }
63
- /** Resolve a path relative to workspace root (where the MCP server is invoked). */
64
62
  function resolve(file) {
65
63
  return path.resolve(getWorkspaceRoot(), file);
66
64
  }
@@ -72,6 +70,17 @@ function readFileSafe(filePath) {
72
70
  return null;
73
71
  }
74
72
  }
73
+ function readJson(filePath) {
74
+ const content = readFileSafe(filePath);
75
+ if (!content)
76
+ return null;
77
+ try {
78
+ return JSON.parse(content);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
75
84
  function loadProjectSpec(wsRoot) {
76
85
  const SPEC_FILE = ".secufusion-project-spec.json";
77
86
  const candidates = [
@@ -100,9 +109,74 @@ function getCurrentBranch(cwd) {
100
109
  return "unknown-branch";
101
110
  }
102
111
  }
103
- // ─────────────────────────────────────────────
104
- // Telemetry & Compression helpers
105
- // ─────────────────────────────────────────────
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ // Migration spec helpers
114
+ // Source of truth: .secufusion-migrations.json
115
+ // SecuFusion uses MANUAL migration scripts — NOT auto-applied on boot.
116
+ // Scripts must be run manually against PostgreSQL BEFORE deployment.
117
+ // Every tool that touches DB-related work calls these helpers.
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ /** Load .secufusion-migrations.json. Searches workspace root then scripts/ subfolder. */
120
+ function loadMigrationSpec(wsRoot) {
121
+ const MIGRATION_FILE = ".secufusion-migrations.json";
122
+ const candidates = [
123
+ path.resolve(wsRoot, MIGRATION_FILE),
124
+ path.resolve(wsRoot, "scripts", MIGRATION_FILE),
125
+ ];
126
+ for (const c of candidates) {
127
+ if (fs.existsSync(c)) {
128
+ try {
129
+ return JSON.parse(readFileSafe(c) || "null");
130
+ }
131
+ catch { }
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ /** Return per-service migration context. Accepts exact or partial service names. */
137
+ function getMigrationContextForService(migrationSpec, serviceName) {
138
+ if (!migrationSpec)
139
+ return null;
140
+ const services = migrationSpec.services ?? {};
141
+ if (services[serviceName])
142
+ return services[serviceName];
143
+ const key = Object.keys(services).find(k => k.includes(serviceName) || serviceName.includes(k.replace("sfn-", "").replace("-api", "")));
144
+ return key ? services[key] : null;
145
+ }
146
+ /** Return the next suggested migration filename (e.g. "V40__{description}.sql"). */
147
+ function getNextMigrationVersion(migrationSpec, serviceName) {
148
+ if (!migrationSpec)
149
+ return null;
150
+ const guardrails = migrationSpec.mcp_guardrails ?? {};
151
+ const perService = guardrails.per_service_next_versions ?? {};
152
+ const serviceKey = Object.keys(perService).find(k => k.includes(serviceName) || serviceName.includes(k.replace("sfn-", "").replace("-api", "")));
153
+ if (serviceKey)
154
+ return perService[serviceKey].example ?? null;
155
+ const ctx = getMigrationContextForService(migrationSpec, serviceName);
156
+ return ctx?.current_state?.next_suggested ?? null;
157
+ }
158
+ /** Return confirmed gap version numbers for a service. Do NOT reuse these. */
159
+ function getMigrationGaps(migrationSpec, serviceName) {
160
+ const ctx = getMigrationContextForService(migrationSpec, serviceName);
161
+ return ctx?.organization?.version_gaps ?? [];
162
+ }
163
+ /** Recursively find files whose name ends with the given suffix. */
164
+ function findFilesRecursive(dir, suffix) {
165
+ const results = [];
166
+ if (!fs.existsSync(dir))
167
+ return results;
168
+ try {
169
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
170
+ const full = path.join(dir, entry.name);
171
+ if (entry.isDirectory())
172
+ results.push(...findFilesRecursive(full, suffix));
173
+ else if (entry.name.endsWith(suffix))
174
+ results.push(full);
175
+ }
176
+ }
177
+ catch { }
178
+ return results;
179
+ }
106
180
  function compressCode(code) {
107
181
  return code
108
182
  .replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments
@@ -335,8 +409,8 @@ server.tool("log_rejected_pattern", "Record a coding pattern that was rejected b
335
409
  // ─── Tool 4: manage_project_spec ─────────────────────────────────────────────
336
410
  server.tool("manage_project_spec", "Manages the .secufusion-project-spec.json file — the permanent project memory containing all service ports, repos, domains, infrastructure config, coding patterns, auth flow, and golden rules. Every session must read this before writing any code.", {
337
411
  action: z
338
- .enum(["read", "update", "get_service", "get_golden_rules", "get_coding_patterns"])
339
- .describe("Action to perform: read (full spec), get_service (one microservice block), get_golden_rules (golden_rules + rejected_patterns), get_coding_patterns (coding_patterns block), update (surgical dot-notation write)."),
412
+ .enum(["read", "update", "get_service", "get_golden_rules", "get_coding_patterns", "get_migration_context"])
413
+ .describe("Action to perform: read (full spec), get_service (one microservice block), get_golden_rules (golden_rules + rejected_patterns), get_coding_patterns (coding_patterns block), update (surgical dot-notation write), get_migration_context (loads migration rules for a service)."),
340
414
  service_name: z
341
415
  .string()
342
416
  .optional()
@@ -465,6 +539,37 @@ server.tool("manage_project_spec", "Manages the .secufusion-project-spec.json fi
465
539
  content: [{ type: "text", text: JSON.stringify(block, null, 2) }],
466
540
  }, inputChars);
467
541
  }
542
+ // ── action: get_migration_context ─────────────────────────────────────────
543
+ if (action === "get_migration_context") {
544
+ if (!service_name) {
545
+ return appendTelemetry({
546
+ isError: true,
547
+ content: [{ type: "text", text: "ERROR: 'service_name' is required when action=get_migration_context." }],
548
+ }, inputChars);
549
+ }
550
+ const migSpec = loadMigrationSpec(getWorkspaceRoot());
551
+ if (!migSpec) {
552
+ return appendTelemetry({
553
+ isError: true,
554
+ content: [{ type: "text", text: "ERROR: .secufusion-migrations.json not found. Run /sfn:init to generate it." }],
555
+ }, inputChars);
556
+ }
557
+ const ctx = getMigrationContextForService(migSpec, service_name);
558
+ if (!ctx) {
559
+ return appendTelemetry({
560
+ isError: true,
561
+ content: [{ type: "text", text: `No migration context found for service: ${service_name}. (Does this service have a database?)` }],
562
+ }, inputChars);
563
+ }
564
+ // Return a combined payload of service context and global guardrails
565
+ const payload = {
566
+ service_context: ctx,
567
+ mcp_guardrails: migSpec.mcp_guardrails ?? {}
568
+ };
569
+ return appendTelemetry({
570
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
571
+ }, inputChars);
572
+ }
468
573
  // ── action: update ────────────────────────────────────────────────────────
469
574
  if (action === "update") {
470
575
  if (!update_path) {
@@ -590,47 +695,47 @@ server.tool("spec_create_intent", "Creates a structured Markdown intent file in
590
695
  ? (services_involved || []).map(s => `| ${s.name} | ${s.language} | ${s.role} |`).join("\n")
591
696
  : "| _(none specified)_ | — | — |";
592
697
  const now = new Date().toISOString();
593
- const content = `---
594
- work_item_id: ${work_item_id}
595
- title: ${title}
596
- status: Draft
597
- number: ${num}
598
- pm_owner: ${pm_owner || "TBD"}
599
- ticket_url: ${ticket_url || "TBD"}
600
- risk: ${risk || "TBD"}
601
- created_at: ${now}
602
- ---
603
-
604
- # [WI-${work_item_id}] ${title}
605
-
606
- ## 🎯 Business Goal
607
-
608
- ${business_goal}
609
-
610
- ## 📋 Acceptance Criteria
611
-
612
- ${acBlock}
613
-
614
- ## 🚫 Out of Scope
615
-
616
- ${oosBlock}
617
-
618
- ## 🌐 Service Map (Polyglot Bridge)
619
-
620
- | Service | Language | Role in this Feature |
621
- |---------|----------|----------------------|
622
- ${servicesBlock}
623
-
624
- ## ⚠️ Risk
625
-
626
- ${risk || "_Not assessed._"}
627
-
628
- ## 📝 Notes
629
-
630
- ${notes || "_None._"}
631
-
632
- ---
633
- _Intent file auto-generated by secufusion-mcp. Edit freely — this is the human layer._
698
+ const content = `---
699
+ work_item_id: ${work_item_id}
700
+ title: ${title}
701
+ status: Draft
702
+ number: ${num}
703
+ pm_owner: ${pm_owner || "TBD"}
704
+ ticket_url: ${ticket_url || "TBD"}
705
+ risk: ${risk || "TBD"}
706
+ created_at: ${now}
707
+ ---
708
+
709
+ # [WI-${work_item_id}] ${title}
710
+
711
+ ## 🎯 Business Goal
712
+
713
+ ${business_goal}
714
+
715
+ ## 📋 Acceptance Criteria
716
+
717
+ ${acBlock}
718
+
719
+ ## 🚫 Out of Scope
720
+
721
+ ${oosBlock}
722
+
723
+ ## 🌐 Service Map (Polyglot Bridge)
724
+
725
+ | Service | Language | Role in this Feature |
726
+ |---------|----------|----------------------|
727
+ ${servicesBlock}
728
+
729
+ ## ⚠️ Risk
730
+
731
+ ${risk || "_Not assessed._"}
732
+
733
+ ## 📝 Notes
734
+
735
+ ${notes || "_None._"}
736
+
737
+ ---
738
+ _Intent file auto-generated by secufusion-mcp. Edit freely — this is the human layer._
634
739
  `;
635
740
  writeFile(filePath, content);
636
741
  return appendTelemetry({
@@ -764,17 +869,6 @@ function generateNewTaskFolder(workItemId, title, tasksRoot) {
764
869
  }
765
870
  return path.join(tasksRoot, `${taskTypeStr}-${numericId}-${slug}`);
766
871
  }
767
- function readJson(filePath) {
768
- const raw = readFileSafe(filePath);
769
- if (!raw)
770
- return null;
771
- try {
772
- return JSON.parse(raw);
773
- }
774
- catch {
775
- return null;
776
- }
777
- }
778
872
  function readRegistry() {
779
873
  return readJson(resolve(REGISTRY_FILE)) || [];
780
874
  }
@@ -1124,10 +1218,10 @@ server.tool("record_retrospective", "Records a structured retrospective after ta
1124
1218
  steps_actual: z.number().describe("How many implementation steps were actually needed."),
1125
1219
  unplanned_files: z.array(z.string()).optional().describe("Files that were modified but NOT in the plan."),
1126
1220
  missed_files: z.array(z.string()).optional().describe("Files that were in the plan but NOT touched."),
1127
- migration_risk_actual: z.string().optional().describe("Actual Flyway migration risk encountered. 'SAFE' | 'RISKY' | 'DANGEROUS' | 'NONE'"),
1221
+ migration_risk_actual: z.string().optional().describe("Actual migration script risk encountered. 'SAFE' | 'RISKY' | 'DANGEROUS' | 'NONE'"),
1128
1222
  breaking_changes_actual: z.number().optional().describe("How many actual breaking changes were found. 0 if none."),
1129
1223
  performance_issues_found: z.boolean().optional().describe("Were any performance issues found during implementation or PR review?"),
1130
- pre_pr_attempts: z.number().describe("How many times run_pre_pr_checks was called before all checks passed. 1 = passed first attempt (perfect), 2+ = needed fixes"),
1224
+ pre_pr_attempts: z.number().describe("How many times /sfn:review was called before all checks passed. 1 = passed first attempt (perfect), 2+ = needed fixes"),
1131
1225
  checks_that_failed: z.array(z.string()).optional().describe("List of check names that failed at least once."),
1132
1226
  scope_creep_detected: z.boolean().describe("Were ACs added or significantly changed during work that were not in the original plan?"),
1133
1227
  scope_creep_description: z.string().optional().describe("Only if scope_creep_detected=true. What changed and why."),
@@ -1518,13 +1612,32 @@ server.tool("philosophy_check", "MANDATORY PHASE -1 STEP — runs BEFORE classif
1518
1612
  const dnaRiskNotes = [];
1519
1613
  const text = `${title} ${description}`.toLowerCase();
1520
1614
  const wordCount = description.trim().split(/\s+/).length;
1615
+ const mentionedServices = dnaServices.filter((s) => text.includes(s));
1521
1616
  // [DNA-1] DB entities mentioned in story — each known entity = real schema blast
1522
- const mentionedEntities = dnaEntities.filter(e => text.includes(e));
1617
+ const mentionedEntities = dnaEntities.filter((e) => text.includes(e));
1523
1618
  if (mentionedEntities.length > 0) {
1524
1619
  const entityRisk = Math.min(mentionedEntities.length * 10, 40);
1525
1620
  dna_risk_bonus += entityRisk;
1526
1621
  dnaRiskNotes.push(`DB entities: ${mentionedEntities.join(", ")} (+${entityRisk})`);
1527
1622
  }
1623
+ // [DNA-1b] Migration Spec Check
1624
+ if (mentionedEntities.length > 0 || /\bmigration\b/i.test(text) || /\btable\b/i.test(text) || /\bcolumn\b/i.test(text)) {
1625
+ const migSpec = loadMigrationSpec(wsRoot);
1626
+ if (migSpec && mentionedServices.length > 0) {
1627
+ mentionedServices.forEach((s) => {
1628
+ const ctx = getMigrationContextForService(migSpec, s);
1629
+ if (ctx && ctx.has_migrations) {
1630
+ dna_risk_bonus += 15;
1631
+ dnaRiskNotes.push(`[MIGRATION SPEC] Manual migration script required for ${s} (Next: ${ctx.next_suggested}) (+15)`);
1632
+ }
1633
+ });
1634
+ }
1635
+ else if (migSpec && mentionedEntities.length > 0) {
1636
+ // If service isn't mentioned by name but entities are, we still want to warn globally
1637
+ dna_risk_bonus += 10;
1638
+ dnaRiskNotes.push(`[MIGRATION SPEC] DB touched. Ensure manual migration scripts are applied to PostgreSQL before deploy. (+10)`);
1639
+ }
1640
+ }
1528
1641
  // [DNA-2] Kafka topics — fan-out cascades to all consumers
1529
1642
  const mentionedTopics = dnaTopics.filter((t) => {
1530
1643
  const tn = (t.name || t.topic || "").toLowerCase();
@@ -1548,7 +1661,6 @@ server.tool("philosophy_check", "MANDATORY PHASE -1 STEP — runs BEFORE classif
1548
1661
  dnaRiskNotes.push("Shared multi-tenant auth — cross-tenant blast (+25)");
1549
1662
  }
1550
1663
  // [DNA-5] Polyglot service coordination — 3+ services = integration risk
1551
- const mentionedServices = dnaServices.filter(s => text.includes(s));
1552
1664
  if (mentionedServices.length >= 3) {
1553
1665
  dna_risk_bonus += 15;
1554
1666
  dnaRiskNotes.push(`Multi-service: ${mentionedServices.slice(0, 4).join(", ")} (+15)`);
@@ -1916,7 +2028,7 @@ server.tool("classify_task", "MANDATORY FIRST STEP for every task without except
1916
2028
  const corpus = (title + " " + description).toLowerCase();
1917
2029
  const BACKEND_SIGNALS = [
1918
2030
  "api", "endpoint", "service", "repository", "database", "db", "query",
1919
- "migration", "flyway", "kafka", "consumer", "producer", "topic",
2031
+ "migration", "kafka", "consumer", "producer", "topic",
1920
2032
  "backend", "server", "rest", "json response", "payload",
1921
2033
  ...learnedSignals.backend, ...learnedSignals.phrase, ...learnedSignals.bug_category
1922
2034
  ];
@@ -2259,7 +2371,7 @@ server.tool("classify_task", "MANDATORY FIRST STEP for every task without except
2259
2371
  "topic", "message", "schema", "kafka", "producer", "consumer", "payload",
2260
2372
  ];
2261
2373
  const DB_CHANGE_SIGNALS = [
2262
- "column", "table", "migration", "flyway", "entity", "@entity",
2374
+ "column", "table", "migration", "entity", "@entity",
2263
2375
  "schema", "alter", "add column", "drop column", "rename column",
2264
2376
  ];
2265
2377
  if (spec && spec.microservices) {
@@ -2615,7 +2727,7 @@ server.tool("analyze_impact", "Traces the SecuFusion inter-service call graph an
2615
2727
  }
2616
2728
  else if (change_type === "entity" || change_type === "field") {
2617
2729
  output += `- Check all \`@Query\` annotations across repositories for references to this field/entity\n`;
2618
- output += `- Include a Flyway migration — flag tier: SAFE/RISKY/DANGEROUS\n`;
2730
+ output += `- Include a migration script in src/main/resources/db/migration/ -- MANUAL, run against PostgreSQL BEFORE deploy -- flag tier: SAFE/RISKY/DANGEROUS\n`;
2619
2731
  }
2620
2732
  else if (change_type === "kafka_topic") {
2621
2733
  output += `- Schema change requires coordinated deployment of producer + ALL consumers\n`;
@@ -2841,7 +2953,7 @@ server.tool("score_change_risk", "Quantifies the risk of a planned change BEFORE
2841
2953
  dbScore += 12;
2842
2954
  findings.push(`⚠️ DB: NOT NULL column without DEFAULT — RISKY (+12 pts). Will fail on non-empty table.`);
2843
2955
  }
2844
- else if (/flyway|migration|@entity|add column/.test(corpus)) {
2956
+ else if (/migration|@entity|add column|alter table/.test(corpus)) {
2845
2957
  dbScore += 3;
2846
2958
  findings.push(`✅ DB: Additive migration detected — SAFE tier (+3 pts).`);
2847
2959
  }
@@ -3251,1330 +3363,6 @@ server.tool("suggest_test_scenarios", "Auto-generates a complete test plan from
3251
3363
  }
3252
3364
  return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
3253
3365
  });
3254
- // ─── Tool 16: run_pre_pr_checks_with_reviewer_agent ──────────────────────────
3255
- // Unified, single-call PR review system. Three sequential tiers that all share
3256
- // the same file-reading pass — no duplicate I/O, no duplicate logic.
3257
- //
3258
- // ┌──────────────────────────────────────────────────────────────────────────┐
3259
- // │ TIER 1 — Mechanical Checks (zero-tolerance, deterministic) │
3260
- // │ P1 TENANT_ISOLATION — every Repository query scoped to tenantId │
3261
- // │ P2 N_PLUS_ONE — no repository calls inside loops │
3262
- // │ P3 MISSING_INDEX — new query columns have a migration index │
3263
- // │ P4 KAFKA_SYNC — no sync DB/REST work inside @KafkaListener │
3264
- // │ P5 EARLY_RETURN — no early returns; single exit point per method │
3265
- // ├──────────────────────────────────────────────────────────────────────────┤
3266
- // │ TIER 2 — AI File Reviewer (runs after Tier 1 passes) │
3267
- // │ Pass 6: reads each changed file and applies architectural heuristics │
3268
- // │ Checks: obvious comments, hardcoded URLs, missing @Transactional, │
3269
- // │ missing @PreAuthorize, debug statements, exception swallowing, │
3270
- // │ TypeScript `any` overuse, missing Flyway for @Entity, cross-service │
3271
- // │ calls without timeouts. Produces inline, file-level comments. │
3272
- // ├──────────────────────────────────────────────────────────────────────────┤
3273
- // │ TIER 3 — Context Reviewer (runs after Tier 2 passes) │
3274
- // │ Reads full task brain: spec, decisions, ACs, scenarios, files-touched │
3275
- // │ + project-spec golden_rules + rejected patterns. │
3276
- // │ Cross-references to find: missed ACs, decision drift, scope creep, │
3277
- // │ rejected pattern violations, test coverage gaps, golden rule breaches. │
3278
- // │ Produces structured findings: CRITICAL / WARNING / INFO / PRAISE │
3279
- // └──────────────────────────────────────────────────────────────────────────┘
3280
- //
3281
- // Verdict: ✅ APPROVED | ❌ CHANGES_REQUESTED | 💬 DISCUSS
3282
- // Cost: Tracked per tier. Tier 1 is free. Tiers 2-3 have per-file cost.
3283
- server.tool("run_pre_pr_checks_with_reviewer_agent", "Unified, single-call PR review system — runs BEFORE raising a PR. " +
3284
- "Three sequential tiers sharing one file-read pass: " +
3285
- "(1) Five deterministic mechanical checks (tenant isolation, N+1, missing index, Kafka sync, early returns). " +
3286
- "(2) AI file reviewer — reads each changed file and applies architectural heuristics (hardcoded URLs, missing annotations, debug statements, etc.). " +
3287
- "(3) Context reviewer — reads the full task brain (spec, ACs, decisions, scenarios) + project-spec golden rules and cross-references against all changed files. " +
3288
- "Verdict: APPROVED (merge ready) | CHANGES_REQUESTED (blocking issues) | DISCUSS (non-blocking concerns). " +
3289
- "Call this in Phase 4 after all ACs are complete. It replaces the former separate run_pre_pr_checks + reviewer_agent calls.", {
3290
- work_item_id: z.string().describe("Active work item ID, e.g. 'WI-1234'."),
3291
- skip_checks: z.array(z.enum(["tenant_isolation", "n_plus_one", "missing_index", "kafka_sync", "early_return", "ai_review", "context_review"])).optional().default([]).describe("Checks to skip. Mechanical: tenant_isolation | n_plus_one | missing_index | kafka_sync | early_return. " +
3292
- "Review tiers: ai_review (Tier 2) | context_review (Tier 3). " +
3293
- "Use sparingly — only for acknowledged legacy files or CI environments where context is unavailable."),
3294
- suppressions: z.array(z.object({
3295
- check: z.enum(["TENANT_ISOLATION", "N_PLUS_ONE", "MISSING_INDEX", "KAFKA_SYNC", "EARLY_RETURN"]),
3296
- method: z.string().describe("Method name to suppress, e.g. 'countOrphanedByDeviceUserId'"),
3297
- reason: z.string().describe("Mandatory justification for suppression")
3298
- })).optional().default([]).describe("Method-scoped suppressions with mandatory reasons.")
3299
- }, async ({ work_item_id, skip_checks = [], suppressions = [] }) => {
3300
- try {
3301
- const cp = require('child_process');
3302
- const __dirname = require('url').fileURLToPath(new URL('.', import.meta.url));
3303
- const scriptPath = require('path').resolve(__dirname, '../../scripts/sfn-pr-check.js');
3304
- const args = JSON.stringify({ work_item_id, skip_checks, suppressions });
3305
- const output = cp.execFileSync(process.execPath, [scriptPath, args], { encoding: 'utf-8' });
3306
- return { content: [{ type: 'text', text: output }] };
3307
- }
3308
- catch (e) {
3309
- return { content: [{ type: 'text', text: e.stdout || e.message }], isError: true };
3310
- }
3311
- });
3312
- // Full-spectrum PR review system.
3313
- //
3314
- // Architecture: two-tier review pipeline
3315
- // Tier 1 — Mechanical checks (Passes 1-5): deterministic, zero-tolerance.
3316
- // Any failure = CHANGES_REQUESTED immediately, no AI pass runs.
3317
- // Tier 2 — AI Reviewer (Pass 6): thoughtful, contextual architectural review.
3318
- // Only runs after all mechanical checks pass.
3319
- // Produces nuanced verdicts: APPROVED / CHANGES_REQUESTED / DISCUSS.
3320
- //
3321
- // Checks:
3322
- // Pass 1 — TENANT_ISOLATION : every *Repository method scoped to tenantId
3323
- // Pass 2 — N_PLUS_ONE : no repository calls inside loops
3324
- // Pass 3 — MISSING_INDEX : new query columns should have a migration index
3325
- // Pass 4 — KAFKA_SYNC : no sync DB/REST work inside @KafkaListener
3326
- // Pass 5 — EARLY_RETURN : no early returns in Java/.ts methods
3327
- // Pass 6 — AI_REVIEWER : thoughtful architectural review (runs after P1-5 clean)
3328
- server.tool("run_pre_pr_checks", "Full-spectrum PR review system — runs BEFORE raising a PR. " +
3329
- "Tier 1: five mechanical checks (tenant isolation, N+1, missing index, Kafka sync, early returns). " +
3330
- "Tier 2: AI-powered architectural reviewer that reads every changed file and produces a thoughtful code review. " +
3331
- "Verdict: APPROVED (merge ready) | CHANGES_REQUESTED (blocking issues found) | DISCUSS (non-blocking concerns worth a conversation). " +
3332
- "Call this in Phase 4 after all ACs are complete. Add check names to skip_checks only for acknowledged legacy files.", {
3333
- work_item_id: z.string().describe("Active work item ID, e.g. 'WI-1234'."),
3334
- skip_checks: z.array(z.enum(["tenant_isolation", "n_plus_one", "missing_index", "kafka_sync", "early_return", "ai_review"])).optional().default([]).describe("Checks to skip. Options: tenant_isolation | n_plus_one | missing_index | kafka_sync | early_return | ai_review. " +
3335
- "Use sparingly — only for acknowledged legacy files that pre-date the rule."),
3336
- suppressions: z.array(z.object({
3337
- check: z.enum(["TENANT_ISOLATION", "N_PLUS_ONE", "MISSING_INDEX", "KAFKA_SYNC", "EARLY_RETURN"]),
3338
- method: z.string().describe("Method name to suppress, e.g. 'countOrphanedByDeviceUserId'"),
3339
- reason: z.string().describe("Mandatory justification for suppression")
3340
- })).optional().default([]).describe("Method-scoped suppressions with mandatory reasons.")
3341
- }, async ({ work_item_id, skip_checks = [], suppressions = [] }) => {
3342
- const inputChars = JSON.stringify({ work_item_id, skip_checks, suppressions }).length;
3343
- const wsRoot = getWorkspaceRoot();
3344
- // ── Resolve task folder ────────────────────────────────────────────────
3345
- const registry = readRegistry();
3346
- const taskEntry = registry.find(r => r["work_item_id"] === work_item_id);
3347
- let taskDir = null;
3348
- if (taskEntry?.["folder_name"]) {
3349
- taskDir = path.resolve(wsRoot, `.secufusion/tasks/${taskEntry["folder_name"]}`);
3350
- }
3351
- if (!taskDir || !fs.existsSync(taskDir)) {
3352
- const tasksBase = path.resolve(wsRoot, ".secufusion/tasks");
3353
- if (fs.existsSync(tasksBase)) {
3354
- const folders = fs.readdirSync(tasksBase);
3355
- const match = folders.find(f => f.startsWith(work_item_id));
3356
- if (match)
3357
- taskDir = path.join(tasksBase, match);
3358
- }
3359
- }
3360
- if (!taskDir || !fs.existsSync(taskDir)) {
3361
- return appendTelemetry({
3362
- isError: true,
3363
- content: [{ type: "text", text: `❌ Task folder not found for ${work_item_id}. Call manage_task(initialize) first.` }]
3364
- }, inputChars);
3365
- }
3366
- // ── Load files-touched ─────────────────────────────────────────────────
3367
- const filesPath = path.join(taskDir, "files-touched.json");
3368
- let filesTouched = [];
3369
- try {
3370
- filesTouched = JSON.parse(readFileSafe(filesPath) || "[]");
3371
- }
3372
- catch { }
3373
- if (filesTouched.length === 0) {
3374
- return appendTelemetry({
3375
- content: [{ type: "text", text: `⚠️ No files logged for ${work_item_id}. Log files with manage_task(log_file_touched) before running checks.` }]
3376
- }, inputChars);
3377
- }
3378
- const fileRecords = [];
3379
- for (const ft of filesTouched) {
3380
- const abs = path.resolve(wsRoot, ft.file_path);
3381
- const raw = readFileSafe(abs) || "";
3382
- const ext = path.extname(ft.file_path).toLowerCase();
3383
- fileRecords.push({
3384
- file_path: ft.file_path,
3385
- change_summary: ft.change_summary,
3386
- content: raw,
3387
- lines: raw.split("\n"),
3388
- ext,
3389
- isJava: ext === ".java",
3390
- isTS: ext === ".ts" || ext === ".tsx",
3391
- isRepo: /Repository/i.test(path.basename(ft.file_path)) && ext === ".java",
3392
- isKafka: raw.includes("@KafkaListener") || raw.includes("KafkaListener"),
3393
- isMigration: ft.file_path.includes("migration") || ft.file_path.includes("flyway") || ext === ".sql",
3394
- });
3395
- }
3396
- const allFindings = [];
3397
- const passResults = [];
3398
- // ══════════════════════════════════════════════════════════════════════
3399
- // PASS 1 — TENANT_ISOLATION
3400
- // Rule: every Repository method must filter by tenantId
3401
- // ══════════════════════════════════════════════════════════════════════
3402
- const p1Findings = [];
3403
- const p1Suppressed = [];
3404
- if (!skip_checks.includes("tenant_isolation")) {
3405
- for (const f of fileRecords.filter(f => f.isRepo)) {
3406
- // Find method signatures in repository interface/class
3407
- const methodPattern = /(?:List|Optional|Page|long|int|boolean|void|[A-Z]\w+)\s+\w+\s*\([^)]*\)/g;
3408
- const queryAnnotations = [];
3409
- f.lines.forEach((line, idx) => {
3410
- if (/@Query\s*\(/i.test(line))
3411
- queryAnnotations.push(idx);
3412
- });
3413
- // Check derived method names
3414
- f.lines.forEach((line, idx) => {
3415
- const derivedMatch = line.match(/(?:find|delete|count|exists)\w+By(\w+)/);
3416
- if (derivedMatch) {
3417
- const byClause = derivedMatch[1].toLowerCase();
3418
- if (!byClause.includes("tenantid") && !byClause.includes("tenant")) {
3419
- let suppressionReason = "";
3420
- const explicitSup = suppressions.find(s => s.check === "TENANT_ISOLATION" && (s.method === derivedMatch[0] || s.method.endsWith(`.${derivedMatch[0]}`)));
3421
- if (explicitSup)
3422
- suppressionReason = explicitSup.reason;
3423
- else {
3424
- for (let i = Math.max(0, idx - 5); i < idx; i++) {
3425
- const match = f.lines[i].match(/@TenantScopeException\s*\(\s*"([^"]+)"\s*\)/i);
3426
- if (match)
3427
- suppressionReason = match[1];
3428
- }
3429
- }
3430
- if (suppressionReason) {
3431
- p1Suppressed.push({ file: f.file_path, line: idx + 1, reason: suppressionReason, method: derivedMatch[0] });
3432
- }
3433
- else {
3434
- p1Findings.push({
3435
- check: "TENANT_ISOLATION",
3436
- severity: "BLOCKING",
3437
- file: f.file_path,
3438
- line: idx + 1,
3439
- message: `Derived query \`${line.trim()}\` does not filter by tenantId`,
3440
- suggestion: "Rename to include AndTenantId: e.g. findByIdAndTenantId(UUID id, UUID tenantId)",
3441
- });
3442
- }
3443
- }
3444
- }
3445
- });
3446
- // Check @Query annotations for tenantId
3447
- queryAnnotations.forEach(annotIdx => {
3448
- // Look at this line and next 3 lines for the query body
3449
- const queryBlock = f.lines.slice(annotIdx, annotIdx + 5).join(" ");
3450
- if (!/tenantId|tenant_id|:tenantId/i.test(queryBlock)) {
3451
- let suppressionReason = "";
3452
- let queryMethodName = "";
3453
- const methodDeclMatch = queryBlock.match(/(?:List|Optional|Page|long|int|boolean|void|[A-Z]\w+)\s+(\w+)\s*\(/);
3454
- if (methodDeclMatch)
3455
- queryMethodName = methodDeclMatch[1];
3456
- if (queryMethodName) {
3457
- const explicitSup = suppressions.find(s => s.check === "TENANT_ISOLATION" && (s.method === queryMethodName || s.method.endsWith(`.${queryMethodName}`)));
3458
- if (explicitSup)
3459
- suppressionReason = explicitSup.reason;
3460
- }
3461
- if (!suppressionReason) {
3462
- for (let i = Math.max(0, annotIdx - 5); i < annotIdx; i++) {
3463
- const match = f.lines[i].match(/@TenantScopeException\s*\(\s*"([^"]+)"\s*\)/i);
3464
- if (match)
3465
- suppressionReason = match[1];
3466
- }
3467
- }
3468
- if (suppressionReason) {
3469
- p1Suppressed.push({ file: f.file_path, line: annotIdx + 1, reason: suppressionReason, method: queryMethodName || "@Query method" });
3470
- }
3471
- else {
3472
- p1Findings.push({
3473
- check: "TENANT_ISOLATION",
3474
- severity: "BLOCKING",
3475
- file: f.file_path,
3476
- line: annotIdx + 1,
3477
- message: `@Query at line ${annotIdx + 1} does not include tenantId filter`,
3478
- suggestion: "Add WHERE e.tenantId = :tenantId to the JPQL/SQL query",
3479
- });
3480
- }
3481
- }
3482
- });
3483
- }
3484
- }
3485
- allFindings.push(...p1Findings);
3486
- passResults.push({ name: "TENANT_ISOLATION", skipped: skip_checks.includes("tenant_isolation"), passed: p1Findings.length === 0, count: p1Findings.length, suppressedCount: p1Suppressed.length });
3487
- // Auto-log suppressions to decisions.json
3488
- if (p1Suppressed.length > 0 && work_item_id) {
3489
- const tasksRoot = resolve(TASKS_DIR);
3490
- const dir = resolveTaskFolder(work_item_id, tasksRoot);
3491
- if (dir) {
3492
- const decisionsPath = path.join(dir, "decisions.json");
3493
- const decisions = readJson(decisionsPath) || [];
3494
- let modified = false;
3495
- for (const sup of p1Suppressed) {
3496
- const desc = `Exempted TENANT_ISOLATION on ${sup.method}`;
3497
- if (!decisions.some(d => d.decision === desc)) {
3498
- decisions.push({
3499
- id: decisions.length + 1,
3500
- decision: desc,
3501
- rationale: sup.reason,
3502
- logged_at: new Date().toISOString()
3503
- });
3504
- modified = true;
3505
- }
3506
- }
3507
- if (modified)
3508
- writeFile(decisionsPath, JSON.stringify(decisions, null, 2));
3509
- }
3510
- }
3511
- // ══════════════════════════════════════════════════════════════════════
3512
- // PASS 2 — N_PLUS_ONE
3513
- // Rule: no repository method calls inside for/forEach loops
3514
- // ══════════════════════════════════════════════════════════════════════
3515
- const p2Findings = [];
3516
- if (!skip_checks.includes("n_plus_one")) {
3517
- for (const f of fileRecords.filter(f => f.isJava || f.isTS)) {
3518
- let braceDepth = 0;
3519
- let inLoop = false;
3520
- let loopStartDepth = -1;
3521
- f.lines.forEach((line, idx) => {
3522
- const openBraces = (line.match(/\{/g) || []).length;
3523
- const closeBraces = (line.match(/\}/g) || []).length;
3524
- const isLoopLine = /\b(for|forEach|stream\(\)|\.map\(|\.forEach\(|while\s*\()/i.test(line);
3525
- if (isLoopLine && !inLoop) {
3526
- inLoop = true;
3527
- loopStartDepth = braceDepth;
3528
- }
3529
- if (inLoop) {
3530
- const repoCallPattern = /\.(findById|findByIdAndTenantId|findAll|findAllBy|save|saveAll|delete|deleteById|existsById|count)\s*\(/;
3531
- if (repoCallPattern.test(line)) {
3532
- p2Findings.push({
3533
- check: "N_PLUS_ONE",
3534
- severity: "BLOCKING",
3535
- file: f.file_path,
3536
- line: idx + 1,
3537
- message: `Repository call \`${(line.match(repoCallPattern) || [""])[0]}\` detected inside a loop`,
3538
- suggestion: "Extract IDs first, then use findAllById() / saveAll() outside the loop",
3539
- });
3540
- }
3541
- }
3542
- braceDepth += openBraces - closeBraces;
3543
- if (inLoop && braceDepth <= loopStartDepth) {
3544
- inLoop = false;
3545
- loopStartDepth = -1;
3546
- }
3547
- });
3548
- }
3549
- }
3550
- allFindings.push(...p2Findings);
3551
- passResults.push({ name: "N_PLUS_ONE", skipped: skip_checks.includes("n_plus_one"), passed: p2Findings.length === 0, count: p2Findings.length });
3552
- // ══════════════════════════════════════════════════════════════════════
3553
- // PASS 3 — MISSING_INDEX
3554
- // Rule: if a new Repository query filters/sorts by a column, the
3555
- // migration MUST contain a CREATE INDEX for that column
3556
- // ══════════════════════════════════════════════════════════════════════
3557
- const p3Findings = [];
3558
- if (!skip_checks.includes("missing_index")) {
3559
- // Collect columns referenced in queries across all repo files
3560
- const queryColumns = [];
3561
- for (const f of fileRecords.filter(f => f.isRepo)) {
3562
- f.lines.forEach((line, idx) => {
3563
- // Derived method columns: findByStatusAndTenantId → status, tenantId
3564
- const derivedMatch = line.match(/(?:find|delete|count|exists)\w+By([\w]+)/);
3565
- if (derivedMatch) {
3566
- const parts = derivedMatch[1].replace(/And|Or/g, ",").split(",");
3567
- for (const p of parts) {
3568
- const col = p.charAt(0).toLowerCase() + p.slice(1);
3569
- if (!["tenantId", "id"].includes(col)) {
3570
- queryColumns.push({ col, file: f.file_path, line: idx + 1 });
3571
- }
3572
- }
3573
- }
3574
- // @Query WHERE clause columns
3575
- const whereMatch = line.match(/WHERE\s+\w+\.(\w+)\s*=/gi);
3576
- if (whereMatch) {
3577
- for (const w of whereMatch) {
3578
- const colMatch = w.match(/\.(\w+)\s*=/i);
3579
- if (colMatch && !["tenantId", "id", "tenant_id"].includes(colMatch[1])) {
3580
- queryColumns.push({ col: colMatch[1], file: f.file_path, line: idx + 1 });
3581
- }
3582
- }
3583
- }
3584
- });
3585
- }
3586
- // Check migrations for those columns
3587
- const migrationContent = fileRecords
3588
- .filter(f => f.isMigration)
3589
- .map(f => f.content.toLowerCase())
3590
- .join("\n");
3591
- for (const { col, file, line } of queryColumns) {
3592
- const snakeCol = col.replace(/([A-Z])/g, "_$1").toLowerCase();
3593
- const indexPattern = new RegExp(`create.*index.*${snakeCol}|create.*index.*${col.toLowerCase()}`, "i");
3594
- if (!indexPattern.test(migrationContent)) {
3595
- // Only flag if there's a migration file in this PR (otherwise we can't verify)
3596
- if (fileRecords.some(f => f.isMigration)) {
3597
- p3Findings.push({
3598
- check: "MISSING_INDEX",
3599
- severity: "WARNING",
3600
- file,
3601
- line,
3602
- message: `Query filters on column \`${col}\` but no CREATE INDEX found in migration files`,
3603
- suggestion: `Add to Flyway migration: CREATE INDEX idx_<table>_${snakeCol} ON <table>(${snakeCol});`,
3604
- });
3605
- }
3606
- }
3607
- }
3608
- }
3609
- allFindings.push(...p3Findings);
3610
- passResults.push({ name: "MISSING_INDEX", skipped: skip_checks.includes("missing_index"), passed: p3Findings.length === 0, count: p3Findings.length });
3611
- // ══════════════════════════════════════════════════════════════════════
3612
- // PASS 4 — KAFKA_SYNC
3613
- // Rule: @KafkaListener methods must NOT do synchronous DB/REST work inline
3614
- // ══════════════════════════════════════════════════════════════════════
3615
- const p4Findings = [];
3616
- if (!skip_checks.includes("kafka_sync")) {
3617
- for (const f of fileRecords.filter(f => f.isKafka)) {
3618
- let inKafkaListener = false;
3619
- let listenerDepth = 0;
3620
- f.lines.forEach((line, idx) => {
3621
- if (/@KafkaListener/.test(line)) {
3622
- inKafkaListener = true;
3623
- listenerDepth = 0;
3624
- }
3625
- if (inKafkaListener) {
3626
- listenerDepth += (line.match(/\{/g) || []).length;
3627
- listenerDepth -= (line.match(/\}/g) || []).length;
3628
- // Detect sync repo/REST calls
3629
- const syncCallPattern = /\.(save|saveAll|findById|findAll|delete|deleteById|restTemplate|webClient|getForObject|postForObject|exchange)\s*\(/;
3630
- if (syncCallPattern.test(line)) {
3631
- const isAsync = /@Async|CompletableFuture|\.async\(\)|executorService|threadPool/i.test(f.lines.slice(Math.max(0, idx - 3), idx + 1).join(" "));
3632
- if (!isAsync) {
3633
- p4Findings.push({
3634
- check: "KAFKA_SYNC",
3635
- severity: "BLOCKING",
3636
- file: f.file_path,
3637
- line: idx + 1,
3638
- message: `Synchronous call \`${(line.match(syncCallPattern) || [""])[0]}\` inside @KafkaListener — blocks consumer thread`,
3639
- suggestion: "Offload to @Async method or submit to a dedicated ExecutorService",
3640
- });
3641
- }
3642
- }
3643
- if (listenerDepth <= 0 && listenerDepth !== 0) {
3644
- inKafkaListener = false;
3645
- }
3646
- }
3647
- });
3648
- }
3649
- }
3650
- allFindings.push(...p4Findings);
3651
- passResults.push({ name: "KAFKA_SYNC", skipped: skip_checks.includes("kafka_sync"), passed: p4Findings.length === 0, count: p4Findings.length });
3652
- // ══════════════════════════════════════════════════════════════════════
3653
- // PASS 5 — EARLY_RETURN
3654
- // Rule: no early returns in methods. Single return point at method end.
3655
- // Applies to Java and TypeScript.
3656
- //
3657
- // Algorithm: inside each method body, any `return` statement that still
3658
- // has non-empty, non-closing-brace lines after it is an early return.
3659
- // This catches both single-line guard clauses (if (x) return null;)
3660
- // and nested-block early returns (if (x) { ... return y; }).
3661
- // ══════════════════════════════════════════════════════════════════════
3662
- const p5Findings = [];
3663
- if (!skip_checks.includes("early_return")) {
3664
- for (const f of fileRecords.filter(f => f.isJava || f.isTS)) {
3665
- let braceDepth = 0;
3666
- let methodStartLine = -1;
3667
- let methodBraceDepth = -1;
3668
- let inMethod = false;
3669
- let methodName = "";
3670
- // Collect all return line indices for current method, flush on method end
3671
- let methodReturnLines = [];
3672
- let methodBodyLines = []; // all non-empty line indices inside method
3673
- const flush = () => {
3674
- // The LAST return in the method is the valid single exit point.
3675
- // Every other return is an early return.
3676
- if (methodReturnLines.length > 1) {
3677
- // All returns except the last are early
3678
- for (const ri of methodReturnLines.slice(0, -1)) {
3679
- p5Findings.push({
3680
- check: "EARLY_RETURN",
3681
- severity: "BLOCKING",
3682
- file: f.file_path,
3683
- line: ri + 1,
3684
- message: `Early return in \`${methodName}()\` — violates single-exit rule`,
3685
- suggestion: "Refactor: declare result at top, use nested if blocks, single return at method end",
3686
- });
3687
- }
3688
- }
3689
- else if (methodReturnLines.length === 1) {
3690
- // Only one return — is it the last meaningful line of the method?
3691
- const returnIdx = methodReturnLines[0];
3692
- // Lines after the return inside the method body (non-empty, not just closing braces)
3693
- const afterReturn = f.lines
3694
- .slice(returnIdx + 1)
3695
- .map((l, offset) => ({ l: l.trim(), absIdx: returnIdx + 1 + offset }))
3696
- .filter(({ l }) => l.length > 0 && l !== "}" && l !== "};" && !l.startsWith("//"));
3697
- // If there's meaningful code after the return before the method ends, it's an early return
3698
- if (afterReturn.length > 0 && afterReturn[0].absIdx <= methodBodyLines[methodBodyLines.length - 1]) {
3699
- p5Findings.push({
3700
- check: "EARLY_RETURN",
3701
- severity: "BLOCKING",
3702
- file: f.file_path,
3703
- line: returnIdx + 1,
3704
- message: `Early return in \`${methodName}()\` — violates single-exit rule`,
3705
- suggestion: "Refactor: declare result at top, use nested if blocks, single return at method end",
3706
- });
3707
- }
3708
- }
3709
- methodReturnLines = [];
3710
- methodBodyLines = [];
3711
- };
3712
- f.lines.forEach((line, idx) => {
3713
- const trimmed = line.trim();
3714
- const openBraces = (line.match(/\{/g) || []).length;
3715
- const closeBraces = (line.match(/\}/g) || []).length;
3716
- // Detect method/function start (before brace depth update)
3717
- const javaMethod = line.match(/(?:public|private|protected|static)\s+\S+\s+(\w+)\s*\(/);
3718
- const tsFunction = line.match(/(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(|async\s+(\w+)\s*\()/);
3719
- const isMethodStart = (f.isJava && javaMethod) || (f.isTS && tsFunction);
3720
- if (isMethodStart && !inMethod) {
3721
- inMethod = true;
3722
- methodBraceDepth = braceDepth;
3723
- methodStartLine = idx;
3724
- methodReturnLines = [];
3725
- methodBodyLines = [];
3726
- methodName = (javaMethod?.[1] || tsFunction?.[1] || tsFunction?.[2] || tsFunction?.[3] || "unknown");
3727
- }
3728
- braceDepth += openBraces - closeBraces;
3729
- if (inMethod && idx > methodStartLine) {
3730
- if (trimmed.length > 0)
3731
- methodBodyLines.push(idx);
3732
- // Track return statements (not in comments)
3733
- const hasReturn = /\breturn\b/.test(line) && !/^\s*\/\//.test(line);
3734
- if (hasReturn) {
3735
- methodReturnLines.push(idx);
3736
- }
3737
- // End of method: brace depth drops back to where method started
3738
- if (braceDepth <= methodBraceDepth) {
3739
- flush();
3740
- inMethod = false;
3741
- methodBraceDepth = -1;
3742
- }
3743
- }
3744
- });
3745
- }
3746
- }
3747
- allFindings.push(...p5Findings);
3748
- passResults.push({ name: "EARLY_RETURN", skipped: skip_checks.includes("early_return"), passed: p5Findings.length === 0, count: p5Findings.length });
3749
- // ── Tier 1 gate: if any mechanical check has BLOCKING findings → stop ──
3750
- const blockingFindings = allFindings.filter(f => f.severity === "BLOCKING");
3751
- const mechanicalPassed = blockingFindings.length === 0;
3752
- const reviewComments = [];
3753
- let aiCost = 0;
3754
- const AI_COST_PER_100_LINES = 0.0008; // ~$0.0008 per 100 lines reviewed
3755
- if (mechanicalPassed && !skip_checks.includes("ai_review")) {
3756
- for (const f of fileRecords) {
3757
- const lineCount = f.lines.length;
3758
- aiCost += (lineCount / 100) * AI_COST_PER_100_LINES;
3759
- // ── 6a: Comment quality check ─────────────────────────────────
3760
- const obviousComments = f.lines.filter(l => /\/\/\s*(get the|set the|loop|return|initialize|this method|this function|create a|update the)/i.test(l));
3761
- if (obviousComments.length > 0) {
3762
- reviewComments.push({
3763
- file: f.file_path,
3764
- kind: "concern",
3765
- comment: `${obviousComments.length} obvious comment(s) detected (e.g. \`// Get the user\`, \`// Loop through list\`). ` +
3766
- "Comments must explain WHY — the code explains WHAT. Remove or replace with rationale.",
3767
- });
3768
- }
3769
- // ── 6b: Hardcoded values ──────────────────────────────────────
3770
- const hardcodedUrls = f.lines.filter((l, i) => /https?:\/\/[0-9]{1,3}\.[0-9]{1,3}|localhost:[0-9]{4}|uat\.|staging\.|prod\./i.test(l) &&
3771
- !/test|spec|mock|comment|\/\//i.test(l));
3772
- if (hardcodedUrls.length > 0) {
3773
- const lineNums = f.lines.map((l, i) => ({ l, i }))
3774
- .filter(({ l }) => /https?:\/\/[0-9]{1,3}\.[0-9]{1,3}|localhost:[0-9]{4}|uat\.|staging\.|prod\./i.test(l))
3775
- .map(({ i }) => i + 1);
3776
- reviewComments.push({
3777
- file: f.file_path,
3778
- line: lineNums[0],
3779
- kind: "concern",
3780
- comment: `Hardcoded URL/IP detected on line(s) ${lineNums.join(", ")}. ` +
3781
- "Move to application.properties / environment variables. This is a PR-blocking violation if merged.",
3782
- });
3783
- }
3784
- // ── 6c: @Transactional(readOnly) on GET service methods ───────
3785
- if (f.isJava && /Service/.test(path.basename(f.file_path))) {
3786
- const getMethods = f.lines
3787
- .map((l, i) => ({ l, i }))
3788
- .filter(({ l }) => /public.*(?:List|Page|Optional|DTO|Response)\s+\w+\s*\(/.test(l));
3789
- for (const { l, i } of getMethods) {
3790
- const prevLines = f.lines.slice(Math.max(0, i - 3), i).join(" ");
3791
- if (!/Transactional.*readOnly.*true|readOnly.*=.*true/.test(prevLines)) {
3792
- reviewComments.push({
3793
- file: f.file_path,
3794
- line: i + 1,
3795
- kind: "concern",
3796
- comment: `Method \`${(l.match(/\w+\s*\(/) || [""])[0].trim()}\` returns data but lacks \`@Transactional(readOnly = true)\`. ` +
3797
- "Read-only service methods MUST declare this — prevents dirty reads and enables DB optimizations.",
3798
- });
3799
- break; // One comment per file, not per method
3800
- }
3801
- }
3802
- }
3803
- // ── 6d: Missing @PreAuthorize on controller methods ───────────
3804
- if (f.isJava && /Controller/.test(path.basename(f.file_path))) {
3805
- const publicEndpoints = f.lines
3806
- .map((l, i) => ({ l, i }))
3807
- .filter(({ l }) => /@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)/.test(l));
3808
- for (const { l, i } of publicEndpoints) {
3809
- const prevLines = f.lines.slice(Math.max(0, i - 5), i).join(" ");
3810
- const hasAuth = /PreAuthorize|permitAll|public\s*\/\*\*/.test(prevLines);
3811
- if (!hasAuth) {
3812
- reviewComments.push({
3813
- file: f.file_path,
3814
- line: i + 1,
3815
- kind: "concern",
3816
- comment: `Endpoint at line ${i + 1} appears to lack \`@PreAuthorize\`. ` +
3817
- "Every non-public endpoint must declare an authority e.g. `@PreAuthorize(\"hasAuthority('domain.action')\")`.",
3818
- });
3819
- }
3820
- }
3821
- }
3822
- // ── 6e: console.log / System.out in production code ───────────
3823
- if (!f.file_path.includes("test") && !f.file_path.includes("spec")) {
3824
- const debugLines = f.lines
3825
- .map((l, i) => ({ l, i }))
3826
- .filter(({ l }) => /console\.log|System\.out\.print|e\.printStackTrace\(\)/.test(l));
3827
- if (debugLines.length > 0) {
3828
- reviewComments.push({
3829
- file: f.file_path,
3830
- line: debugLines[0].i + 1,
3831
- kind: "concern",
3832
- comment: `Debug statement on line ${debugLines[0].i + 1} (\`${debugLines[0].l.trim()}\`). ` +
3833
- "Use SLF4J logger or structured logging. Never leave console.log / System.out in production.",
3834
- });
3835
- }
3836
- }
3837
- // ── 6f: Catch-all exception swallowing ────────────────────────
3838
- if (f.isJava) {
3839
- f.lines.forEach((line, idx) => {
3840
- if (/catch\s*\(\s*Exception\s+/.test(line)) {
3841
- const catchBlock = f.lines.slice(idx, idx + 5).join(" ");
3842
- if (/\}\s*$/.test(catchBlock) && !/log\.|logger\.|throw/.test(catchBlock)) {
3843
- reviewComments.push({
3844
- file: f.file_path,
3845
- line: idx + 1,
3846
- kind: "concern",
3847
- comment: `Broad \`catch (Exception e)\` at line ${idx + 1} may be swallowing errors silently. ` +
3848
- "Either log the exception with context or re-throw as a domain exception.",
3849
- });
3850
- }
3851
- }
3852
- });
3853
- }
3854
- // ── 6g: TypeScript any type usage ─────────────────────────────
3855
- if (f.isTS && !f.file_path.includes("@ts-nocheck")) {
3856
- const anyLines = f.lines
3857
- .map((l, i) => ({ l, i }))
3858
- .filter(({ l }) => /:\s*any\b/.test(l) && !/\/\/.*:\s*any/.test(l));
3859
- if (anyLines.length > 2) {
3860
- reviewComments.push({
3861
- file: f.file_path,
3862
- kind: "nitpick",
3863
- comment: `${anyLines.length} uses of \`: any\` type found. ` +
3864
- "Prefer explicit interfaces or generics. `any` defeats type safety — use `unknown` or define the shape.",
3865
- });
3866
- }
3867
- }
3868
- // ── 6h: Missing Flyway migration for new @Entity ───────────────
3869
- if (f.isJava && /@Entity/.test(f.content)) {
3870
- const hasMigration = fileRecords.some(r => r.isMigration);
3871
- if (!hasMigration) {
3872
- reviewComments.push({
3873
- file: f.file_path,
3874
- kind: "concern",
3875
- comment: `New or modified \`@Entity\` class with no Flyway migration in this PR. ` +
3876
- "Every JPA entity change MUST have a corresponding Flyway SQL migration.",
3877
- });
3878
- }
3879
- }
3880
- // ── 6i: Praise well-structured code ───────────────────────────
3881
- const hasProperLogging = /log\.(info|debug|warn|error)\(/.test(f.content);
3882
- const hasTests = fileRecords.some(r => r.file_path.includes("Test") || r.file_path.includes("Spec"));
3883
- if (hasProperLogging && !f.file_path.includes("Test")) {
3884
- reviewComments.push({
3885
- file: f.file_path,
3886
- kind: "praise",
3887
- comment: "Proper structured logging with SLF4J observed — good practice.",
3888
- });
3889
- }
3890
- // ── 6j: Cross-service call timeout check ──────────────────────
3891
- if (f.isJava && /RestTemplate|WebClient|FeignClient/i.test(f.content)) {
3892
- const hasTimeout = /connectTimeout|readTimeout|timeout/i.test(f.content);
3893
- if (!hasTimeout) {
3894
- reviewComments.push({
3895
- file: f.file_path,
3896
- kind: "concern",
3897
- comment: `RestTemplate/WebClient/FeignClient usage detected but no timeout configuration found. ` +
3898
- "All cross-service calls MUST have explicit connectTimeout and readTimeout.",
3899
- });
3900
- }
3901
- }
3902
- }
3903
- }
3904
- // ── Determine final verdict ────────────────────────────────────────────
3905
- const blockingReviewConcerns = reviewComments.filter(c => c.kind === "concern").length;
3906
- const questions = reviewComments.filter(c => c.kind === "question").length;
3907
- const nitpicks = reviewComments.filter(c => c.kind === "nitpick").length;
3908
- const praises = reviewComments.filter(c => c.kind === "praise").length;
3909
- let verdict;
3910
- let verdictReason;
3911
- if (blockingFindings.length > 0) {
3912
- verdict = "❌ CHANGES_REQUESTED";
3913
- verdictReason = `${blockingFindings.length} blocking mechanical check(s) must be resolved before this PR can merge.`;
3914
- }
3915
- else if (blockingReviewConcerns > 0) {
3916
- verdict = "❌ CHANGES_REQUESTED";
3917
- verdictReason = `All mechanical checks passed, but the reviewer found ${blockingReviewConcerns} architectural concern(s) that must be addressed.`;
3918
- }
3919
- else if (nitpicks > 0 || questions > 0) {
3920
- verdict = "💬 DISCUSS";
3921
- verdictReason = `All checks passed. ${nitpicks} nitpick(s) and ${questions} question(s) worth discussing — non-blocking but address before next iteration.`;
3922
- }
3923
- else {
3924
- verdict = "✅ APPROVED";
3925
- verdictReason = "All mechanical checks passed. Reviewer found no architectural concerns. PR is ready to merge.";
3926
- }
3927
- // ── Build output ──────────────────────────────────────────────────────
3928
- const totalChecked = fileRecords.length;
3929
- const skippedList = skip_checks.length > 0 ? ` (skipped: ${skip_checks.join(", ")})` : "";
3930
- let out = `## run_pre_pr_checks — ${work_item_id}\n\n`;
3931
- out += `📁 **${totalChecked} file(s) reviewed**${skippedList}\n\n`;
3932
- // ── Pass summary table ────────────────────────────────────────────────
3933
- out += `### Mechanical Checks (Tier 1)\n`;
3934
- out += `| Pass | Check | Status | Findings |\n|---|---|---|---|\n`;
3935
- for (const p of passResults) {
3936
- const statusIcon = p.skipped ? "⏭️ SKIPPED" : p.passed ? "✅ PASS" : "❌ FAIL";
3937
- const issueText = p.skipped ? "—" : (p.suppressedCount ? `${p.count} (${p.suppressedCount} suppressed)` : `${p.count}`);
3938
- out += `| P${passResults.indexOf(p) + 1} | ${p.name} | ${statusIcon} | ${issueText} |\n`;
3939
- }
3940
- // ── Blocking findings detail ──────────────────────────────────────────
3941
- if (blockingFindings.length > 0) {
3942
- out += `\n### ❌ Blocking Findings (${blockingFindings.length})\n`;
3943
- for (const f of blockingFindings) {
3944
- out += `\n**[${f.check}]** \`${f.file}\`${f.line ? ` line ${f.line}` : ""}\n`;
3945
- out += `> ${f.message}\n`;
3946
- out += `> 💡 *${f.suggestion}*\n`;
3947
- }
3948
- }
3949
- if (p1Suppressed.length > 0) {
3950
- out += `\n### 🛡️ Suppressed Findings (${p1Suppressed.length})\n`;
3951
- for (const sup of p1Suppressed) {
3952
- out += `\n**[TENANT_ISOLATION]** \`${sup.file}\` line ${sup.line}\n`;
3953
- out += `> Suppressed — ${sup.reason}\n`;
3954
- }
3955
- }
3956
- // ── Warning findings detail ───────────────────────────────────────────
3957
- const warnings = allFindings.filter(f => f.severity === "WARNING");
3958
- if (warnings.length > 0) {
3959
- out += `\n### ⚠️ Warnings (${warnings.length})\n`;
3960
- for (const f of warnings) {
3961
- out += `\n**[${f.check}]** \`${f.file}\`${f.line ? ` line ${f.line}` : ""}\n`;
3962
- out += `> ${f.message}\n`;
3963
- out += `> 💡 *${f.suggestion}*\n`;
3964
- }
3965
- }
3966
- // ── AI Reviewer comments ──────────────────────────────────────────────
3967
- if (!skip_checks.includes("ai_review")) {
3968
- if (!mechanicalPassed) {
3969
- out += `\n### 🤖 AI Reviewer (Pass 6)\n`;
3970
- out += `> ⏸️ Skipped — resolve mechanical check failures first.\n`;
3971
- }
3972
- else if (reviewComments.length > 0) {
3973
- out += `\n### 🤖 AI Reviewer (Pass 6) — ${reviewComments.length} comment(s)\n`;
3974
- const concerns = reviewComments.filter(c => c.kind === "concern");
3975
- const nits = reviewComments.filter(c => c.kind === "nitpick");
3976
- const qs = reviewComments.filter(c => c.kind === "question");
3977
- const ps = reviewComments.filter(c => c.kind === "praise");
3978
- if (concerns.length > 0) {
3979
- out += `\n#### 🔴 Concerns (must address)\n`;
3980
- for (const c of concerns) {
3981
- out += `\n**\`${c.file}\`**${c.line ? ` line ${c.line}` : ""}\n`;
3982
- out += `> ${c.comment}\n`;
3983
- }
3984
- }
3985
- if (nits.length > 0) {
3986
- out += `\n#### 🟡 Nitpicks (consider addressing)\n`;
3987
- for (const c of nits) {
3988
- out += `\n**\`${c.file}\`**${c.line ? ` line ${c.line}` : ""}\n`;
3989
- out += `> ${c.comment}\n`;
3990
- }
3991
- }
3992
- if (qs.length > 0) {
3993
- out += `\n#### 💬 Questions\n`;
3994
- for (const c of qs) {
3995
- out += `\n**\`${c.file}\`**${c.line ? ` line ${c.line}` : ""}\n`;
3996
- out += `> ${c.comment}\n`;
3997
- }
3998
- }
3999
- if (ps.length > 0) {
4000
- out += `\n#### ✅ Praise\n`;
4001
- for (const c of ps) {
4002
- out += `- \`${c.file}\`: ${c.comment}\n`;
4003
- }
4004
- }
4005
- // Estimated cost
4006
- out += `\n> 💰 **AI Review cost:** ~$${aiCost.toFixed(4)} (${totalChecked} files × avg line count)\n`;
4007
- }
4008
- else {
4009
- out += `\n### 🤖 AI Reviewer (Pass 6)\n`;
4010
- out += `> ✅ No architectural concerns found across all ${totalChecked} changed file(s).\n`;
4011
- out += `> 💰 **AI Review cost:** ~$${aiCost.toFixed(4)}\n`;
4012
- }
4013
- }
4014
- // ── Final verdict ─────────────────────────────────────────────────────
4015
- out += `\n---\n`;
4016
- out += `## ${verdict}\n\n`;
4017
- out += `${verdictReason}\n`;
4018
- if (verdict === "✅ APPROVED") {
4019
- out += `\n**Next steps:**\n`;
4020
- out += `1. Call \`manage_task(action: "complete", work_item_id: "${work_item_id}")\`\n`;
4021
- out += `2. Generate PR and ADO documents manually as per Phase 4\n`;
4022
- out += `3. Raise the PR\n`;
4023
- }
4024
- else if (verdict === "❌ CHANGES_REQUESTED") {
4025
- out += `\n**Resolve all ❌ findings above, then re-run \`run_pre_pr_checks\` before raising the PR.**\n`;
4026
- }
4027
- else {
4028
- out += `\n**Address nitpicks/questions at your discretion, then raise the PR.**\n`;
4029
- }
4030
- return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
4031
- });
4032
- // ─── Tool 17: reviewer_agent ──────────────────────────────────────────────────
4033
- // Context-aware architectural review agent. Called automatically in Phase 4
4034
- // AFTER run_pre_pr_checks returns APPROVED or DISCUSS.
4035
- //
4036
- // Unlike run_pre_pr_checks (which runs deterministic pattern checks on raw code),
4037
- // reviewer_agent works at the task-knowledge level:
4038
- // — Reads the full task brain: spec, decisions, ACs, scenarios, files-touched
4039
- // — Reads project-spec golden_rules and coding_style
4040
- // — Reads actual content of every changed file
4041
- // — Cross-references all of the above to find:
4042
- // • ACs with no matching file changes (missed requirements)
4043
- // • Architectural decisions not reflected in files (decision drift)
4044
- // • Changed files not mentioned in any decision (scope creep)
4045
- // • Rejected pattern violations in file content
4046
- // • Test scenario coverage gaps
4047
- // • Golden rule compliance per file
4048
- //
4049
- // Returns findings grouped by severity:
4050
- // 🔴 CRITICAL — blocks PR, must fix
4051
- // 🟡 WARNING — should address before merge
4052
- // 🟢 INFO — observations, not blocking
4053
- // ✅ PRAISE — things done well
4054
- //
4055
- // Final verdict: APPROVED | CHANGES_REQUESTED | DISCUSS
4056
- server.tool("reviewer_agent", "Context-aware architectural review agent — call this in Phase 4 AFTER run_pre_pr_checks returns APPROVED or DISCUSS. " +
4057
- "Reads the full task brain (spec, decisions, ACs, scenarios, files-touched) + project-spec golden rules + actual changed file contents. " +
4058
- "Cross-references all of them to find: missed ACs, decision drift, scope creep, rejected pattern violations, test coverage gaps, and golden rule breaches. " +
4059
- "Returns a structured review report with findings grouped by severity (CRITICAL / WARNING / INFO / PRAISE) and a final verdict. " +
4060
- "This is the final quality gate before manage_task(complete) is called.", {
4061
- work_item_id: z.string().describe("Active work item ID, e.g. 'WI-1234'."),
4062
- focus_areas: z.array(z.enum(["ac_coverage", "decision_drift", "scope_creep", "rejected_patterns", "test_coverage", "golden_rules", "all"])).optional().default(["all"]).describe("Which review areas to focus on. Default: all. " +
4063
- "Options: ac_coverage | decision_drift | scope_creep | rejected_patterns | test_coverage | golden_rules | all"),
4064
- }, async ({ work_item_id, focus_areas = ["all"] }) => {
4065
- const inputChars = JSON.stringify({ work_item_id, focus_areas }).length;
4066
- const wsRoot = getWorkspaceRoot();
4067
- const runAll = focus_areas.includes("all");
4068
- const run = (area) => runAll || focus_areas.includes(area);
4069
- // ── 1. Resolve task folder ─────────────────────────────────────────────
4070
- const registry = readRegistry();
4071
- const taskEntry = registry.find(r => r["work_item_id"] === work_item_id);
4072
- let taskDir = null;
4073
- if (taskEntry?.["folder_name"]) {
4074
- taskDir = path.resolve(wsRoot, `.secufusion/tasks/${taskEntry["folder_name"]}`);
4075
- }
4076
- if (!taskDir || !fs.existsSync(taskDir)) {
4077
- const tasksBase = path.resolve(wsRoot, ".secufusion/tasks");
4078
- if (fs.existsSync(tasksBase)) {
4079
- const folders = fs.readdirSync(tasksBase);
4080
- const match = folders.find(f => f.startsWith(work_item_id));
4081
- if (match)
4082
- taskDir = path.join(tasksBase, match);
4083
- }
4084
- }
4085
- if (!taskDir || !fs.existsSync(taskDir)) {
4086
- return appendTelemetry({
4087
- isError: true,
4088
- content: [{ type: "text", text: `❌ Task folder not found for ${work_item_id}. Call manage_task(initialize) first.` }]
4089
- }, inputChars);
4090
- }
4091
- const spec = JSON.parse(readFileSafe(path.join(taskDir, "spec.json")) || "{}");
4092
- const progress = JSON.parse(readFileSafe(path.join(taskDir, "progress.json")) || "{}");
4093
- const decisions = JSON.parse(readFileSafe(path.join(taskDir, "decisions.json")) || "[]");
4094
- const filesTouched = JSON.parse(readFileSafe(path.join(taskDir, "files-touched.json")) || "[]");
4095
- const scenarios = JSON.parse(readFileSafe(path.join(taskDir, "scenarios.json")) || "[]");
4096
- // ── 3. Load project spec (golden rules + rejected patterns) ────────────
4097
- const SPEC_FILE = ".secufusion-project-spec.json";
4098
- const specCandidates = [
4099
- path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")), SPEC_FILE),
4100
- path.resolve(wsRoot, SPEC_FILE),
4101
- ];
4102
- const specPath = specCandidates.find(p => fs.existsSync(p)) ?? null;
4103
- let projectSpec = null;
4104
- if (specPath) {
4105
- try {
4106
- projectSpec = JSON.parse(readFileSafe(specPath) || "null");
4107
- }
4108
- catch { }
4109
- }
4110
- const goldenRules = projectSpec?.golden_rules || {};
4111
- const codingStyle = goldenRules.coding_style || {};
4112
- const authorityRules = goldenRules.authority_rules || [];
4113
- // Load rejected patterns
4114
- const REJECTED_FILE_PATH = resolve(".rejected-patterns.json");
4115
- let rejectedPatterns = [];
4116
- try {
4117
- rejectedPatterns = JSON.parse(readFileSafe(REJECTED_FILE_PATH) || "[]");
4118
- }
4119
- catch { }
4120
- const fileRecords = filesTouched.map(ft => {
4121
- const abs = path.resolve(wsRoot, ft.file_path);
4122
- const raw = readFileSafe(abs) || "";
4123
- const ext = path.extname(ft.file_path).toLowerCase();
4124
- return {
4125
- file_path: ft.file_path,
4126
- change_summary: ft.change_summary,
4127
- content: raw,
4128
- lines: raw.split("\n"),
4129
- ext,
4130
- isJava: ext === ".java",
4131
- isTS: ext === ".ts" || ext === ".tsx",
4132
- isTest: /test|spec/i.test(ft.file_path),
4133
- exists: fs.existsSync(abs),
4134
- };
4135
- });
4136
- const findings = [];
4137
- // ══════════════════════════════════════════════════════════════════════
4138
- // AREA 1 — AC COVERAGE
4139
- // Cross-reference every AC against files-touched change summaries.
4140
- // An AC with zero matches across all change summaries is likely missed.
4141
- // ══════════════════════════════════════════════════════════════════════
4142
- if (run("ac_coverage")) {
4143
- const allACs = [
4144
- ...(spec.acceptance_criteria || []),
4145
- ...(progress.completed_acs || []),
4146
- ...(progress.pending_acs || []),
4147
- ];
4148
- const uniqueACs = [...new Set(allACs)];
4149
- // Combined corpus: file paths + change summaries + decisions
4150
- const changeCorpus = [
4151
- ...fileRecords.map(f => f.file_path + " " + f.change_summary),
4152
- ...decisions.map(d => d.decision + " " + d.rationale),
4153
- ].join(" ").toLowerCase();
4154
- for (const ac of uniqueACs) {
4155
- if (!ac || ac.trim().length === 0)
4156
- continue;
4157
- // Extract key nouns/verbs from AC (3+ char words, excluding articles)
4158
- const keywords = ac.toLowerCase()
4159
- .split(/\s+/)
4160
- .filter(w => w.length > 3 && !/^(must|shall|should|the|and|for|with|that|this|when|from|into|not|does|will|have|been|each|only|any|all)$/.test(w));
4161
- const matchCount = keywords.filter(kw => changeCorpus.includes(kw)).length;
4162
- const coverage = keywords.length > 0 ? matchCount / keywords.length : 1;
4163
- if (coverage < 0.25) {
4164
- findings.push({
4165
- severity: "CRITICAL",
4166
- area: "AC_COVERAGE",
4167
- title: `AC may not be implemented`,
4168
- detail: `"${ac.substring(0, 100)}${ac.length > 100 ? "..." : ""}" — no matching file changes or decisions found`,
4169
- action: "Verify this AC is addressed by a file in files-touched. If it is, update the change_summary to reflect it.",
4170
- });
4171
- }
4172
- else if (coverage < 0.5) {
4173
- findings.push({
4174
- severity: "WARNING",
4175
- area: "AC_COVERAGE",
4176
- title: `AC has weak coverage signal`,
4177
- detail: `"${ac.substring(0, 100)}${ac.length > 100 ? "..." : ""}" — only ${Math.round(coverage * 100)}% of keywords found in change summaries`,
4178
- action: "Confirm this AC is fully satisfied. If so, enrich the change_summary for the relevant file.",
4179
- });
4180
- }
4181
- }
4182
- // Pending ACs check
4183
- if ((progress.pending_acs || []).length > 0) {
4184
- findings.push({
4185
- severity: "CRITICAL",
4186
- area: "AC_COVERAGE",
4187
- title: `${progress.pending_acs.length} AC(s) still pending`,
4188
- detail: progress.pending_acs.map(ac => `• ${ac}`).join("\n"),
4189
- action: "Complete all ACs before raising a PR. Call manage_task(update_spec) to move them to completed_acs.",
4190
- });
4191
- }
4192
- }
4193
- // ══════════════════════════════════════════════════════════════════════
4194
- // AREA 2 — DECISION DRIFT
4195
- // Every architectural decision in decisions.json should be traceable
4196
- // to at least one file in files-touched.
4197
- // ══════════════════════════════════════════════════════════════════════
4198
- if (run("decision_drift")) {
4199
- for (const dec of decisions) {
4200
- if (!dec.decision)
4201
- continue;
4202
- // Skip rollback/meta decisions (they don't produce files)
4203
- if (/rollback|strategy|tier|SAFE|RISKY|DANGEROUS/i.test(dec.decision))
4204
- continue;
4205
- const decKeywords = dec.decision.toLowerCase()
4206
- .split(/\s+/)
4207
- .filter(w => w.length > 4 && !/^(using|added|decided|chose|going|will|this|that|with|from|into|the|and|for)$/.test(w));
4208
- const fileCorpus = fileRecords.map(f => f.file_path + " " + f.change_summary).join(" ").toLowerCase();
4209
- const matchCount = decKeywords.filter(kw => fileCorpus.includes(kw)).length;
4210
- const traceability = decKeywords.length > 0 ? matchCount / decKeywords.length : 1;
4211
- if (traceability < 0.2 && decKeywords.length >= 3) {
4212
- findings.push({
4213
- severity: "WARNING",
4214
- area: "DECISION_DRIFT",
4215
- title: `Decision #${dec.id} may not be reflected in code`,
4216
- detail: `Decision: "${dec.decision?.substring(0, 120)}${(dec.decision?.length || 0) > 120 ? "..." : ""}"`,
4217
- action: "Verify this decision was implemented. If the file exists but wasn't logged, call manage_task(log_file_touched).",
4218
- });
4219
- }
4220
- }
4221
- }
4222
- // ══════════════════════════════════════════════════════════════════════
4223
- // AREA 3 — SCOPE CREEP
4224
- // Files in files-touched that don't appear in any AC, decision, or spec
4225
- // description are potential scope creep.
4226
- // ══════════════════════════════════════════════════════════════════════
4227
- if (run("scope_creep")) {
4228
- const specCorpus = [
4229
- spec.title || "",
4230
- spec.description || "",
4231
- ...(spec.acceptance_criteria || []),
4232
- ...decisions.map(d => d.decision + " " + d.rationale),
4233
- ].join(" ").toLowerCase();
4234
- for (const f of fileRecords.filter(f => !f.isTest)) {
4235
- const basename = path.basename(f.file_path).replace(/\.[^.]+$/, "").toLowerCase();
4236
- // Split camelCase/PascalCase: DeviceController → device controller
4237
- const words = basename.replace(/([A-Z])/g, " $1").toLowerCase().trim().split(/[\s_-]+/).filter(w => w.length > 3);
4238
- const inSpec = words.some(w => specCorpus.includes(w));
4239
- if (!inSpec && words.length > 0) {
4240
- findings.push({
4241
- severity: "INFO",
4242
- area: "SCOPE_CREEP",
4243
- title: `File may be outside task scope`,
4244
- detail: `\`${f.file_path}\` — no mention of this component in spec, ACs, or decisions`,
4245
- file: f.file_path,
4246
- action: "If this file change is intentional, add a decision: manage_task(log_decision) explaining why it was touched.",
4247
- });
4248
- }
4249
- }
4250
- }
4251
- // ══════════════════════════════════════════════════════════════════════
4252
- // AREA 4 — REJECTED PATTERNS
4253
- // Scan file contents against .rejected-patterns.json
4254
- // ══════════════════════════════════════════════════════════════════════
4255
- if (run("rejected_patterns") && rejectedPatterns.length > 0) {
4256
- for (const f of fileRecords.filter(f => f.exists)) {
4257
- for (const rp of rejectedPatterns) {
4258
- if (!rp.pattern)
4259
- continue;
4260
- // Build a simple keyword match from the pattern
4261
- const patternKeywords = rp.pattern.toLowerCase().split(/\s+/).filter(w => w.length > 4).slice(0, 4);
4262
- const fileContent = f.content.toLowerCase();
4263
- const matchCount = patternKeywords.filter(kw => fileContent.includes(kw)).length;
4264
- if (patternKeywords.length > 0 && matchCount / patternKeywords.length >= 0.75) {
4265
- findings.push({
4266
- severity: "CRITICAL",
4267
- area: "REJECTED_PATTERNS",
4268
- title: `Rejected pattern detected in file`,
4269
- detail: `Pattern: "${rp.pattern}"\nReason: ${rp.reason || "See .rejected-patterns.json"}`,
4270
- file: f.file_path,
4271
- action: `This pattern was explicitly rejected (category: ${rp.category || "unknown"}). Refactor before merging.`,
4272
- });
4273
- }
4274
- }
4275
- }
4276
- }
4277
- // ══════════════════════════════════════════════════════════════════════
4278
- // AREA 5 — TEST COVERAGE
4279
- // Are there test files in the touched set? Do scenarios have file coverage?
4280
- // ══════════════════════════════════════════════════════════════════════
4281
- if (run("test_coverage")) {
4282
- const testFiles = fileRecords.filter(f => f.isTest);
4283
- const sourceFiles = fileRecords.filter(f => !f.isTest && (f.isJava || f.isTS));
4284
- const unitScenarios = scenarios.filter(s => s.type === "unit");
4285
- const integrationScenarios = scenarios.filter(s => s.type === "integration");
4286
- if (sourceFiles.length > 0 && testFiles.length === 0) {
4287
- findings.push({
4288
- severity: "CRITICAL",
4289
- area: "TEST_COVERAGE",
4290
- title: "No test files in this PR",
4291
- detail: `${sourceFiles.length} source file(s) changed but zero test files logged in files-touched`,
4292
- action: "Add unit and integration tests. Call manage_task(log_file_touched) for each test file.",
4293
- });
4294
- }
4295
- else if (testFiles.length > 0) {
4296
- findings.push({
4297
- severity: "PRAISE",
4298
- area: "TEST_COVERAGE",
4299
- title: `${testFiles.length} test file(s) included`,
4300
- detail: testFiles.map(f => `• ${f.file_path}`).join("\n"),
4301
- });
4302
- }
4303
- if (unitScenarios.length === 0 && sourceFiles.length > 0) {
4304
- findings.push({
4305
- severity: "WARNING",
4306
- area: "TEST_COVERAGE",
4307
- title: "No unit test scenarios logged",
4308
- detail: "manage_task(add_scenario) with type='unit' was never called for this task",
4309
- action: "Log at least one unit test scenario, or call suggest_test_scenarios to auto-generate them.",
4310
- });
4311
- }
4312
- if (integrationScenarios.length === 0 && sourceFiles.some(f => /Controller/i.test(f.file_path))) {
4313
- findings.push({
4314
- severity: "WARNING",
4315
- area: "TEST_COVERAGE",
4316
- title: "Controller changed but no integration test scenarios",
4317
- detail: "A Controller file was modified but no integration scenarios were logged",
4318
- action: "Add an integration test scenario via manage_task(add_scenario) with type='integration'.",
4319
- });
4320
- }
4321
- }
4322
- // ══════════════════════════════════════════════════════════════════════
4323
- // AREA 6 — GOLDEN RULES compliance
4324
- // Check file content against project-spec golden_rules
4325
- // ══════════════════════════════════════════════════════════════════════
4326
- if (run("golden_rules")) {
4327
- // Coding style: no early returns (secondary check, run_pre_pr_checks is primary)
4328
- if (codingStyle.banned_pattern) {
4329
- for (const f of fileRecords.filter(f => f.isJava || f.isTS)) {
4330
- // Check for the most obvious banned pattern signatures
4331
- const hasBannedReturn = /if\s*\([^)]+\)\s*return\s+[^;]+;/.test(f.content);
4332
- if (hasBannedReturn && !f.isTest) {
4333
- findings.push({
4334
- severity: "WARNING",
4335
- area: "GOLDEN_RULES",
4336
- title: "Possible coding_style violation: early return pattern",
4337
- detail: `\`${f.file_path}\` may contain early returns matching banned pattern: \`${codingStyle.banned_pattern}\``,
4338
- file: f.file_path,
4339
- action: "Run run_pre_pr_checks to confirm. Refactor to nested conditionals if flagged.",
4340
- });
4341
- }
4342
- }
4343
- }
4344
- // Authority rules: controller methods need @PreAuthorize
4345
- if (authorityRules.length > 0) {
4346
- for (const f of fileRecords.filter(f => f.isJava && /Controller/.test(f.file_path) && !f.isTest)) {
4347
- const hasPreAuth = f.content.includes("@PreAuthorize");
4348
- const hasEndpoints = /@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)/.test(f.content);
4349
- if (hasEndpoints && !hasPreAuth) {
4350
- findings.push({
4351
- severity: "CRITICAL",
4352
- area: "GOLDEN_RULES",
4353
- title: "Controller missing @PreAuthorize",
4354
- detail: `\`${f.file_path}\` has endpoint mappings but no @PreAuthorize annotation`,
4355
- file: f.file_path,
4356
- action: "Add @PreAuthorize(\"hasAuthority('<domain>.<action>')\") to every non-public endpoint.",
4357
- });
4358
- }
4359
- else if (hasEndpoints && hasPreAuth) {
4360
- findings.push({
4361
- severity: "PRAISE",
4362
- area: "GOLDEN_RULES",
4363
- title: "@PreAuthorize present on controller",
4364
- detail: `\`${f.file_path}\` correctly uses @PreAuthorize for endpoint security`,
4365
- });
4366
- }
4367
- }
4368
- }
4369
- // Tenant isolation spot check
4370
- for (const f of fileRecords.filter(f => f.isJava && /Repository/.test(f.file_path) && !f.isTest)) {
4371
- const hasTenantFilter = /tenantId|tenant_id|:tenantId/i.test(f.content);
4372
- const hasQueries = /@Query|findBy|deleteBy/i.test(f.content);
4373
- if (hasQueries && hasTenantFilter) {
4374
- findings.push({
4375
- severity: "PRAISE",
4376
- area: "GOLDEN_RULES",
4377
- title: "Tenant isolation applied in repository",
4378
- detail: `\`${f.file_path}\` correctly scopes queries with tenantId`,
4379
- });
4380
- }
4381
- }
4382
- // Flyway migration check for @Entity changes
4383
- const entityFiles = fileRecords.filter(f => f.isJava && /@Entity/.test(f.content) && !f.isTest);
4384
- const migrationFiles = fileRecords.filter(f => f.file_path.includes("migration") || f.ext === ".sql");
4385
- if (entityFiles.length > 0 && migrationFiles.length === 0) {
4386
- findings.push({
4387
- severity: "CRITICAL",
4388
- area: "GOLDEN_RULES",
4389
- title: "@Entity modified with no Flyway migration",
4390
- detail: `${entityFiles.length} @Entity file(s) touched: ${entityFiles.map(f => path.basename(f.file_path)).join(", ")}`,
4391
- action: "Create a Flyway migration for every schema change. Log it with manage_task(log_file_touched).",
4392
- });
4393
- }
4394
- else if (entityFiles.length > 0 && migrationFiles.length > 0) {
4395
- findings.push({
4396
- severity: "PRAISE",
4397
- area: "GOLDEN_RULES",
4398
- title: "@Entity change has matching Flyway migration",
4399
- detail: `${migrationFiles.map(f => path.basename(f.file_path)).join(", ")}`,
4400
- });
4401
- }
4402
- }
4403
- // ── General praise ─────────────────────────────────────────────────────
4404
- if (decisions.length >= 3) {
4405
- findings.push({
4406
- severity: "PRAISE",
4407
- area: "DECISIONS",
4408
- title: `${decisions.length} architectural decisions logged`,
4409
- detail: "Good decision logging discipline — future agents will benefit from this history.",
4410
- });
4411
- }
4412
- if (scenarios.length >= 5) {
4413
- findings.push({
4414
- severity: "PRAISE",
4415
- area: "TEST_COVERAGE",
4416
- title: `${scenarios.length} test scenarios logged`,
4417
- detail: "Comprehensive scenario coverage documented for this task.",
4418
- });
4419
- }
4420
- // ── Verdict ────────────────────────────────────────────────────────────
4421
- const criticals = findings.filter(f => f.severity === "CRITICAL");
4422
- const warnings = findings.filter(f => f.severity === "WARNING");
4423
- const infos = findings.filter(f => f.severity === "INFO");
4424
- const praises = findings.filter(f => f.severity === "PRAISE");
4425
- let verdict;
4426
- let verdictDetail;
4427
- if (criticals.length > 0) {
4428
- verdict = "❌ CHANGES_REQUESTED";
4429
- verdictDetail = `${criticals.length} critical finding(s) must be resolved. Fix them, update files-touched, then re-run reviewer_agent.`;
4430
- }
4431
- else if (warnings.length > 2) {
4432
- verdict = "💬 DISCUSS";
4433
- verdictDetail = `No critical issues. ${warnings.length} warnings worth reviewing before merge.`;
4434
- }
4435
- else if (warnings.length > 0) {
4436
- verdict = "✅ APPROVED";
4437
- verdictDetail = `No critical issues. ${warnings.length} minor warning(s) — address at your discretion.`;
4438
- }
4439
- else {
4440
- verdict = "✅ APPROVED";
4441
- verdictDetail = "Full context review passed. Task is well-documented and implementation is aligned with spec.";
4442
- }
4443
- // ── Build output ───────────────────────────────────────────────────────
4444
- let out = `## reviewer_agent — ${work_item_id}\n\n`;
4445
- // Task context summary
4446
- out += `### 📋 Task Context\n`;
4447
- out += `**Title:** ${spec.title || "(untitled)"}\n`;
4448
- out += `**Status:** ${spec.status || "(unknown)"}\n`;
4449
- out += `**ACs:** ${(spec.acceptance_criteria || []).length} total | `;
4450
- out += `${(progress.completed_acs || []).length} completed | `;
4451
- out += `${(progress.pending_acs || []).length} pending\n`;
4452
- out += `**Decisions logged:** ${decisions.length}\n`;
4453
- out += `**Files touched:** ${fileRecords.length} (${fileRecords.filter(f => !f.exists).length} missing from disk)\n`;
4454
- out += `**Scenarios logged:** ${scenarios.length} (${scenarios.filter(s => s.type === "unit").length} unit, ${scenarios.filter(s => s.type === "integration").length} integration, ${scenarios.filter(s => s.type === "manual").length} manual)\n`;
4455
- out += `**Focus areas:** ${focus_areas.join(", ")}\n\n`;
4456
- // Files reviewed
4457
- if (fileRecords.length > 0) {
4458
- out += `### 📁 Files Reviewed\n`;
4459
- for (const f of fileRecords) {
4460
- const missing = !f.exists ? " ⚠️ *(not found on disk)*" : "";
4461
- out += `- \`${f.file_path}\`${missing}\n`;
4462
- if (f.change_summary)
4463
- out += ` *${f.change_summary}*\n`;
4464
- }
4465
- out += "\n";
4466
- }
4467
- // CRITICAL findings
4468
- if (criticals.length > 0) {
4469
- out += `### 🔴 CRITICAL — Must Fix Before PR (${criticals.length})\n\n`;
4470
- for (const f of criticals) {
4471
- out += `**[${f.area}] ${f.title}**\n`;
4472
- out += `> ${f.detail.replace(/\n/g, "\n> ")}\n`;
4473
- if (f.action)
4474
- out += `> 🔧 *${f.action}*\n`;
4475
- out += "\n";
4476
- }
4477
- }
4478
- // WARNING findings
4479
- if (warnings.length > 0) {
4480
- out += `### 🟡 WARNING — Should Address (${warnings.length})\n\n`;
4481
- for (const f of warnings) {
4482
- out += `**[${f.area}] ${f.title}**\n`;
4483
- out += `> ${f.detail.replace(/\n/g, "\n> ")}\n`;
4484
- if (f.action)
4485
- out += `> 💡 *${f.action}*\n`;
4486
- out += "\n";
4487
- }
4488
- }
4489
- // INFO findings
4490
- if (infos.length > 0) {
4491
- out += `### 🟢 INFO — Observations (${infos.length})\n\n`;
4492
- for (const f of infos) {
4493
- out += `**[${f.area}] ${f.title}**\n`;
4494
- out += `> ${f.detail.replace(/\n/g, "\n> ")}\n`;
4495
- if (f.action)
4496
- out += `> 💡 *${f.action}*\n`;
4497
- out += "\n";
4498
- }
4499
- }
4500
- // PRAISE
4501
- const extSpec3 = specData.chrome_extension || specData["snf-browser-extn"] || specData.snf_browser_extn;
4502
- if (extSpec3 && extSpec3.repo) {
4503
- expectedServices.push(extSpec3.repo);
4504
- }
4505
- if (praises.length > 0) {
4506
- out += `### ✅ PRAISE\n`;
4507
- for (const f of praises) {
4508
- out += `- **${f.title}:** ${f.detail.replace(/\n/g, " ")}\n`;
4509
- }
4510
- out += "\n";
4511
- }
4512
- // Summary counts
4513
- out += `---\n`;
4514
- out += `| Severity | Count |\n|---|---|\n`;
4515
- out += `| 🔴 CRITICAL | ${criticals.length} |\n`;
4516
- out += `| 🟡 WARNING | ${warnings.length} |\n`;
4517
- out += `| 🟢 INFO | ${infos.length} |\n`;
4518
- out += `| ✅ PRAISE | ${praises.length} |\n\n`;
4519
- // Final verdict
4520
- out += `## ${verdict}\n\n${verdictDetail}\n`;
4521
- if (verdict === "✅ APPROVED") {
4522
- out += `\n**Next steps:**\n`;
4523
- out += `1. Call \`manage_task(action: "complete", work_item_id: "${work_item_id}")\`\n`;
4524
- out += `2. Generate PR and ADO documents manually as per Phase 4\n`;
4525
- out += `3. Raise the PR\n`;
4526
- }
4527
- else if (verdict === "❌ CHANGES_REQUESTED") {
4528
- out += `\n**Resolve all 🔴 CRITICAL findings, update files-touched if needed, then re-run \`reviewer_agent\`.**\n`;
4529
- }
4530
- else {
4531
- out += `\n**Discuss warnings with your team, then proceed with \`manage_task(complete)\`.**\n`;
4532
- }
4533
- return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
4534
- });
4535
- // ─── Resource & Prompt for Personas ──────────────────────────────────────────
4536
- const AGENTS_DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")), "../../agents");
4537
- const PLANNER_MD_PATH = path.join(AGENTS_DIR, "planner.md");
4538
- const CODER_MD_PATH = path.join(AGENTS_DIR, "coder.md");
4539
- const REVIEWER_MD_PATH = path.join(AGENTS_DIR, "reviewer.md");
4540
- const SKILLS_CATALOG_PATH = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")), "skills-catalog.json");
4541
- server.prompt("secufusion_planner", "Starts a conversation with the SecuFusion planner workflow rules loaded (Phase 00 - Phase 1).", async () => {
4542
- const content = readFileSafe(PLANNER_MD_PATH);
4543
- if (!content)
4544
- throw new Error(`Rules file not found at ${PLANNER_MD_PATH}`);
4545
- return {
4546
- messages: [{ role: "user", content: { type: "text", text: `You are now the SecuFusion Planner. Here are your rules:\n\n${content}` } }]
4547
- };
4548
- });
4549
- server.prompt("secufusion_coder", "Starts a conversation with the SecuFusion coder workflow rules loaded (Phase 2 - Phase 3).", async () => {
4550
- const content = readFileSafe(CODER_MD_PATH);
4551
- if (!content)
4552
- throw new Error(`Rules file not found at ${CODER_MD_PATH}`);
4553
- return {
4554
- messages: [{ role: "user", content: { type: "text", text: `You are now the SecuFusion Coder. Here are your rules:\n\n${content}` } }]
4555
- };
4556
- });
4557
- server.prompt("secufusion_reviewer", "Starts a conversation with the SecuFusion reviewer workflow rules loaded (Phase 4 - Phase 5).", async () => {
4558
- const content = readFileSafe(REVIEWER_MD_PATH);
4559
- if (!content)
4560
- throw new Error(`Rules file not found at ${REVIEWER_MD_PATH}`);
4561
- return {
4562
- messages: [{ role: "user", content: { type: "text", text: `You are now the SecuFusion Reviewer. Here are your rules:\n\n${content}` } }]
4563
- };
4564
- });
4565
- // ─── Slash Commands (Aliases for Personas / Workflows) ───────────────────────
4566
- server.prompt("sfn_spec", "Slash command to start Phase 0.5 (Classification) and Phase 1 (Planning)", async () => {
4567
- const content = readFileSafe(PLANNER_MD_PATH);
4568
- return { messages: [{ role: "user", content: { type: "text", text: `[Command: /sfn_spec]\nYou are in Spec/Planning mode. Execute Phase 00, Phase 0.5, and Phase 1 rules:\n\n${content}` } }] };
4569
- });
4570
- server.prompt("sfn_code", "Slash command to start Phase 2 (Execution) and Phase 3 (Course Correction)", async () => {
4571
- const content = readFileSafe(CODER_MD_PATH);
4572
- return { messages: [{ role: "user", content: { type: "text", text: `[Command: /sfn_code]\nYou are in Coder mode. Execute Phase 2 and Phase 3 rules:\n\n${content}` } }] };
4573
- });
4574
- server.prompt("sfn_review", "Slash command to start Phase 4 (PR Checks) and Phase 5 (Retrospective)", async () => {
4575
- const content = readFileSafe(REVIEWER_MD_PATH);
4576
- return { messages: [{ role: "user", content: { type: "text", text: `[Command: /sfn_review]\nYou are in Review mode. Execute Phase 4 and Phase 5 rules:\n\n${content}` } }] };
4577
- });
4578
3366
  // ─── Tools ─────────────────────────────────────────────
4579
3367
  server.tool("skill_recommend", "Searches the skill catalog for matching domain skills based on keywords.", {
4580
3368
  keywords: z.array(z.string()).describe("Keywords to search for (e.g., 'kafka', 'react')")
@@ -4599,6 +3387,17 @@ server.tool("skill_recommend", "Searches the skill catalog for matching domain s
4599
3387
  });
4600
3388
  let watcher = null;
4601
3389
  function getEcosystemRoot(workspaceRoot) {
3390
+ // If workspaceRoot itself already contains multiple sibling service repos
3391
+ // (the flat-monorepo-of-repos layout), trust it as-is — don't walk up.
3392
+ if (fs.existsSync(workspaceRoot)) {
3393
+ const ownChildren = fs.readdirSync(workspaceRoot);
3394
+ const ownSfnRepos = ownChildren.filter((c) => c.startsWith("sfn-") || c === "secufusion");
3395
+ if (ownSfnRepos.length > 1) {
3396
+ return workspaceRoot;
3397
+ }
3398
+ }
3399
+ // Otherwise, workspaceRoot may be a single service repo (e.g. sfn-auth-api)
3400
+ // opened on its own — walk up to find the shared ecosystem parent.
4602
3401
  const parentDir = path.dirname(workspaceRoot);
4603
3402
  if (fs.existsSync(parentDir)) {
4604
3403
  const parentChildren = fs.readdirSync(parentDir);
@@ -4673,7 +3472,65 @@ function getPolyRepoSourceFiles(ecosystemRoot) {
4673
3472
  }
4674
3473
  return allFiles;
4675
3474
  }
3475
+ function generateMigrationSpec(ecosystemRoot) {
3476
+ const migrationsSpec = {
3477
+ mcp_guardrails: {
3478
+ naming_pattern_to_enforce: "^V\\d+__[a-z][a-z0-9_]*\\.sql$",
3479
+ version_must_be_next_in_sequence: true,
3480
+ no_gap_reuse_allowed: true,
3481
+ apply_manually_to: "postgresql"
3482
+ },
3483
+ microservices: {}
3484
+ };
3485
+ try {
3486
+ const dirs = fs.readdirSync(ecosystemRoot, { withFileTypes: true })
3487
+ .filter(dirent => dirent.isDirectory() && !dirent.name.startsWith('.') && dirent.name !== 'node_modules')
3488
+ .map(dirent => dirent.name);
3489
+ for (const dir of dirs) {
3490
+ const migrationPath = path.join(ecosystemRoot, dir, 'src', 'main', 'resources', 'db', 'migration');
3491
+ if (fs.existsSync(migrationPath)) {
3492
+ const files = fs.readdirSync(migrationPath).filter(f => f.endsWith('.sql'));
3493
+ const versions = files
3494
+ .map(f => {
3495
+ const match = f.match(/^V(\d+)__/);
3496
+ return match ? parseInt(match[1], 10) : null;
3497
+ })
3498
+ .filter(v => v !== null);
3499
+ versions.sort((a, b) => a - b);
3500
+ let latest = 0;
3501
+ const gaps = [];
3502
+ if (versions.length > 0) {
3503
+ latest = versions[versions.length - 1];
3504
+ for (let i = 1; i < latest; i++) {
3505
+ if (!versions.includes(i)) {
3506
+ gaps.push(i);
3507
+ }
3508
+ }
3509
+ }
3510
+ migrationsSpec.microservices[dir] = {
3511
+ has_migrations: true,
3512
+ path: "src/main/resources/db/migration/",
3513
+ current_state: {
3514
+ latest_version: latest,
3515
+ next_suggested: `V${latest + 1}__description_here.sql`
3516
+ },
3517
+ organization: {
3518
+ version_gaps: gaps,
3519
+ total_scripts: files.length
3520
+ }
3521
+ };
3522
+ }
3523
+ }
3524
+ const outPath = path.join(ecosystemRoot, '.secufusion-migrations.json');
3525
+ fs.writeFileSync(outPath, JSON.stringify(migrationsSpec, null, 2), 'utf-8');
3526
+ }
3527
+ catch (e) {
3528
+ console.error("Failed to generate migration spec", e);
3529
+ }
3530
+ }
4676
3531
  function performFullExtraction(ecosystemRoot) {
3532
+ // Generate Migration Spec on every extraction
3533
+ generateMigrationSpec(ecosystemRoot);
4677
3534
  const sourceFiles = getPolyRepoSourceFiles(ecosystemRoot);
4678
3535
  const models = [];
4679
3536
  const apis = [];