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.
- package/README.md +5 -3
- package/agents/AGENTS.md +224 -31
- package/agents/reviewer.md +231 -304
- package/commands/sfn-code.md +72 -6
- package/commands/sfn-init.md +506 -36
- package/commands/sfn-review.md +695 -30
- package/mcp/dist/parsers/events.js +11 -4
- package/mcp/dist/server.js +253 -1396
- package/package.json +2 -2
- package/scripts/.secufusion-migrations.json +395 -0
- package/scripts/.secufusion-project-spec.json +857 -0
package/mcp/dist/server.js
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
|
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;
|
|
57
|
+
break;
|
|
59
58
|
currentDir = parent;
|
|
60
59
|
}
|
|
61
|
-
return ROOT;
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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", "
|
|
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", "
|
|
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
|
|
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 (/
|
|
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 = [];
|