speclock 5.0.2 → 5.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 CHANGED
@@ -8,7 +8,7 @@
8
8
  <a href="https://www.npmjs.com/package/speclock"><img src="https://img.shields.io/npm/v/speclock.svg?style=flat-square&color=4F46E5" alt="npm version" /></a>
9
9
  <a href="https://www.npmjs.com/package/speclock"><img src="https://img.shields.io/npm/dm/speclock.svg?style=flat-square&color=22C55E" alt="npm downloads" /></a>
10
10
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="MIT License" /></a>
11
- <a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-39_tools-green.svg?style=flat-square" alt="MCP 39 tools" /></a>
11
+ <a href="https://modelcontextprotocol.io"><img src="https://img.shields.io/badge/MCP-42_tools-green.svg?style=flat-square" alt="MCP 42 tools" /></a>
12
12
  </p>
13
13
 
14
14
  <p align="center">
@@ -32,7 +32,7 @@ AI: ⚠️ BLOCKED — violates lock "Never touch the auth system"
32
32
  Should I find another approach?
33
33
  ```
34
34
 
35
- **940 tests. 99.4% pass rate. 0 false positives across 13 suites. Gemini Flash hybrid, Spec Compiler, Code Graph, Typed Constraints, Python SDK, ROS2 integration.**
35
+ **1073 tests. 99.4% pass rate. 0 false positives across 15 suites. Gemini Flash hybrid, Spec Compiler, Code Graph, Typed Constraints, Python SDK, ROS2 integration.**
36
36
 
37
37
  ---
38
38
 
@@ -339,7 +339,7 @@ GET /api/v2/graph/lock-map
339
339
 
340
340
  ---
341
341
 
342
- ## 39 MCP Tools
342
+ ## 42 MCP Tools
343
343
 
344
344
  <details>
345
345
  <summary><b>Memory</b> — goal, locks, decisions, notes, deploy facts</summary>
@@ -508,7 +508,7 @@ The AI opens the file and sees:
508
508
  │ AI Tool (Claude Code, Cursor, Bolt.new...) │
509
509
  └────────────┬──────────────────┬──────────────────┘
510
510
  │ │
511
- MCP Protocol (39 tools) npm File-Based
511
+ MCP Protocol (42 tools) npm File-Based
512
512
  │ (SPECLOCK.md + CLI)
513
513
  │ │
514
514
  ┌────────────▼──────────────────▼──────────────────┐
@@ -571,7 +571,7 @@ The AI opens the file and sees:
571
571
  | Python SDK | 62 | 100% | pip install, constraint checking |
572
572
  | ROS2 Guardian | 26 | 100% | Robot safety constraint enforcement |
573
573
  | Real-World Testers | 105 | 95% | 5 developers, 30+ locks, diverse domains |
574
- | **Total** | **940** | **99.4%** | **13 suites, 15 domains** |
574
+ | **Total** | **940** | **99.4%** | **15 suites, 15 domains** |
575
575
 
576
576
  Tested across: fintech, e-commerce, IoT, healthcare, SaaS, gaming, biotech, aerospace, payments, payroll, robotics, autonomous systems. All 11 Indian payment gateways detected. Zero false positives on UI/cosmetic actions.
577
577
 
@@ -611,4 +611,4 @@ Built by **[Sandeep Roy](https://github.com/sgroy10)**
611
611
 
612
612
  ---
613
613
 
614
- <p align="center"><i>v5.0.0 — 940 tests, 99.4% pass rate, 39 MCP tools, Spec Compiler, Code Graph, Typed Constraints, Python SDK, ROS2, REST API v2. Because remembering isn't enough.</i></p>
614
+ <p align="center"><i>v5.0.0 — 1073 tests, 99.4% pass rate, 42 MCP tools, Spec Compiler, Code Graph, Typed Constraints, Python SDK, ROS2, REST API v2. Because remembering isn't enough.</i></p>
package/package.json CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  "name": "speclock",
4
4
 
5
- "version": "5.0.2",
5
+ "version": "5.2.0",
6
6
 
7
- "description": "AI Constraint Engine for autonomous systems governance. Spec Compiler (NL→constraints), Code Graph (blast radius, lock-to-file mapping), Typed constraints (numerical, range, state, temporal), REST API v2, Python SDK, ROS2 integration. 39 MCP tools, Gemini LLM hybrid, HMAC audit chain, RBAC, encryption, SOC 2/HIPAA compliance.",
7
+ "description": "AI Constraint Engine AI Patch Firewall. Diff-native review (interface breaks, protected symbols, dependency drift, schema changes, API impact), Patch Gateway (ALLOW/WARN/BLOCK verdicts), Spec Compiler (NL→constraints), Code Graph (blast radius, lock-to-file mapping), Typed constraints, REST API v2, Python SDK, ROS2 integration. 42 MCP tools, Gemini LLM hybrid, HMAC audit chain, RBAC, encryption, SOC 2/HIPAA compliance.",
8
8
 
9
9
  "type": "module",
10
10
 
package/src/cli/index.js CHANGED
@@ -117,7 +117,7 @@ function refreshContext(root) {
117
117
 
118
118
  function printHelp() {
119
119
  console.log(`
120
- SpecLock v5.0.0 — AI Constraint Engine (Spec Compiler + Code Graph + Typed Constraints + Python SDK + ROS2 + REST API v2 + Gemini LLM + Policy-as-Code + Auth + RBAC + Encryption)
120
+ SpecLock v5.2.0 — AI Constraint Engine (Spec Compiler + Code Graph + Typed Constraints + Python SDK + ROS2 + REST API v2 + Gemini LLM + Policy-as-Code + Auth + RBAC + Encryption)
121
121
  Developed by Sandeep Roy (github.com/sgroy10)
122
122
 
123
123
  Usage: speclock <command> [options]
@@ -9,7 +9,7 @@
9
9
  import { readBrain, readEvents } from "./storage.js";
10
10
  import { verifyAuditChain } from "./audit.js";
11
11
 
12
- const VERSION = "5.0.0";
12
+ const VERSION = "5.2.0";
13
13
 
14
14
  // PHI-related keywords for HIPAA filtering
15
15
  const PHI_KEYWORDS = [
@@ -0,0 +1,547 @@
1
+ // ===================================================================
2
+ // SpecLock Diff Analyzer — Signal Extraction & Scoring
3
+ // Takes parsed diff + project context → scored signals for each
4
+ // risk dimension (interface break, protected symbol, dependency
5
+ // drift, schema change, public API impact).
6
+ //
7
+ // Developed by Sandeep Roy (https://github.com/sgroy10)
8
+ // ===================================================================
9
+
10
+ import { readBrain } from "./storage.js";
11
+ import { mapLocksToFiles, getBlastRadius, getOrBuildGraph } from "./code-graph.js";
12
+ import { analyzeConflict } from "./semantics.js";
13
+
14
+ // --- Signal score caps (from ChatGPT spec) ---
15
+
16
+ const CAPS = {
17
+ semanticConflict: 20,
18
+ lockFileOverlap: 20,
19
+ blastRadius: 15,
20
+ typedConstraintRelevance: 10,
21
+ llmConflict: 10,
22
+ interfaceBreak: 15,
23
+ protectedSymbolEdit: 15,
24
+ dependencyDrift: 8,
25
+ schemaChange: 12,
26
+ publicApiImpact: 15,
27
+ };
28
+
29
+ // --- Critical dependency keywords ---
30
+ const CRITICAL_DEPS = new Set([
31
+ "express", "fastify", "koa", "hapi",
32
+ "react", "vue", "angular", "svelte",
33
+ "mongoose", "sequelize", "prisma", "typeorm", "knex", "drizzle",
34
+ "stripe", "razorpay", "paypal",
35
+ "jsonwebtoken", "jwt", "passport", "bcrypt", "argon2",
36
+ "firebase", "supabase", "aws-sdk", "@aws-sdk",
37
+ "pg", "mysql", "sqlite3", "redis", "mongodb",
38
+ ]);
39
+
40
+ /**
41
+ * Analyze a parsed diff against project constraints.
42
+ *
43
+ * @param {string} root - Project root
44
+ * @param {object} parsedDiff - Output from parseDiff()
45
+ * @param {string} description - Change description
46
+ * @param {object} options - Analysis options
47
+ * @returns {object} Scored signals and reasons
48
+ */
49
+ export function analyzeDiff(root, parsedDiff, description, options = {}) {
50
+ const {
51
+ includeSymbolAnalysis = true,
52
+ includeDependencyAnalysis = true,
53
+ includeSchemaAnalysis = true,
54
+ includeApiAnalysis = true,
55
+ } = options;
56
+
57
+ const brain = readBrain(root);
58
+ const activeLocks = (brain?.specLock?.items || []).filter(l => l.active !== false);
59
+ const textLocks = activeLocks.filter(l => !l.constraintType);
60
+ const typedLocks = activeLocks.filter(l => l.constraintType);
61
+
62
+ const signals = {};
63
+ const reasons = [];
64
+
65
+ // --- 1. Semantic Conflict (reuse v5.1 logic) ---
66
+ signals.semanticConflict = scoreSemanticConflict(description, textLocks);
67
+ for (const r of signals.semanticConflict.reasons) reasons.push(r);
68
+
69
+ // --- 2. Lock-File Overlap ---
70
+ const filePaths = parsedDiff.files.map(f => f.path);
71
+ signals.lockFileOverlap = scoreLockFileOverlap(root, filePaths);
72
+ for (const r of signals.lockFileOverlap.reasons) reasons.push(r);
73
+
74
+ // --- 3. Blast Radius ---
75
+ signals.blastRadius = scoreBlastRadius(root, filePaths);
76
+ for (const r of signals.blastRadius.reasons) reasons.push(r);
77
+
78
+ // --- 4. Interface Break ---
79
+ if (includeSymbolAnalysis) {
80
+ signals.interfaceBreak = scoreInterfaceBreak(parsedDiff);
81
+ for (const r of signals.interfaceBreak.reasons) reasons.push(r);
82
+ } else {
83
+ signals.interfaceBreak = { score: 0, changes: [], reasons: [] };
84
+ }
85
+
86
+ // --- 5. Protected Symbol Edit ---
87
+ if (includeSymbolAnalysis) {
88
+ signals.protectedSymbolEdit = scoreProtectedSymbolEdit(root, parsedDiff);
89
+ for (const r of signals.protectedSymbolEdit.reasons) reasons.push(r);
90
+ } else {
91
+ signals.protectedSymbolEdit = { score: 0, changes: [], reasons: [] };
92
+ }
93
+
94
+ // --- 6. Dependency Drift ---
95
+ if (includeDependencyAnalysis) {
96
+ signals.dependencyDrift = scoreDependencyDrift(parsedDiff);
97
+ for (const r of signals.dependencyDrift.reasons) reasons.push(r);
98
+ } else {
99
+ signals.dependencyDrift = { score: 0, changes: [], reasons: [] };
100
+ }
101
+
102
+ // --- 7. Schema Change ---
103
+ if (includeSchemaAnalysis) {
104
+ signals.schemaChange = scoreSchemaChange(parsedDiff);
105
+ for (const r of signals.schemaChange.reasons) reasons.push(r);
106
+ } else {
107
+ signals.schemaChange = { score: 0, changes: [], reasons: [] };
108
+ }
109
+
110
+ // --- 8. Public API Impact ---
111
+ if (includeApiAnalysis) {
112
+ signals.publicApiImpact = scorePublicApiImpact(parsedDiff);
113
+ for (const r of signals.publicApiImpact.reasons) reasons.push(r);
114
+ } else {
115
+ signals.publicApiImpact = { score: 0, changes: [], reasons: [] };
116
+ }
117
+
118
+ // --- 9. Typed Constraint Relevance ---
119
+ signals.typedConstraintRelevance = scoreTypedConstraintRelevance(description, typedLocks);
120
+
121
+ // --- 10. LLM Conflict (placeholder — filled async) ---
122
+ signals.llmConflict = { score: 0, used: false, reasons: [] };
123
+
124
+ return { signals, reasons };
125
+ }
126
+
127
+ // --- Individual signal scorers ---
128
+
129
+ function scoreSemanticConflict(description, textLocks) {
130
+ const result = { score: 0, matchedLocks: 0, reasons: [] };
131
+
132
+ for (const lock of textLocks) {
133
+ const analysis = analyzeConflict(description, lock.text);
134
+ if (analysis.isConflict) {
135
+ result.matchedLocks++;
136
+ const confidence = (analysis.confidence || 0) / 100; // normalize to 0-1
137
+ const lockScore = Math.min(CAPS.semanticConflict, Math.round(confidence * CAPS.semanticConflict));
138
+ result.score = Math.max(result.score, lockScore);
139
+ result.reasons.push({
140
+ type: "semantic_conflict",
141
+ severity: confidence >= 0.7 ? "critical" : "high",
142
+ confidence,
143
+ message: `Semantic conflict with lock: "${lock.text}"`,
144
+ details: { lockId: lock.id, lockText: lock.text },
145
+ });
146
+ }
147
+ }
148
+
149
+ return result;
150
+ }
151
+
152
+ function scoreLockFileOverlap(root, filePaths) {
153
+ const result = { score: 0, matchedFiles: [], reasons: [] };
154
+ if (filePaths.length === 0) return result;
155
+
156
+ try {
157
+ const lockMap = mapLocksToFiles(root);
158
+ const normalizedFiles = filePaths.map(f => f.replace(/\\/g, "/").toLowerCase());
159
+
160
+ for (const mapping of lockMap) {
161
+ for (const mf of mapping.matchedFiles) {
162
+ const mfNorm = mf.toLowerCase();
163
+ const overlap = normalizedFiles.find(cf =>
164
+ cf === mfNorm || cf.endsWith("/" + mfNorm) || mfNorm.endsWith("/" + cf)
165
+ );
166
+ if (overlap) {
167
+ result.matchedFiles.push({ file: overlap, lock: mapping.lockText });
168
+ result.reasons.push({
169
+ type: "lock_file_overlap",
170
+ severity: "critical",
171
+ confidence: 0.99,
172
+ message: `Changed file falls inside locked zone.`,
173
+ details: { file: overlap, lock: mapping.lockText, lockId: mapping.lockId },
174
+ });
175
+ }
176
+ }
177
+ }
178
+
179
+ result.score = Math.min(CAPS.lockFileOverlap, result.matchedFiles.length * 10);
180
+ } catch (_) {
181
+ // No graph available
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ function scoreBlastRadius(root, filePaths) {
188
+ const result = { score: 0, highImpactFiles: [], reasons: [] };
189
+ if (filePaths.length === 0) return result;
190
+
191
+ try {
192
+ for (const file of filePaths) {
193
+ const br = getBlastRadius(root, file);
194
+ if (br.found && br.impactPercent > 10) {
195
+ result.highImpactFiles.push({
196
+ file: br.file,
197
+ transitiveDependents: br.blastRadius || 0,
198
+ impactPercent: br.impactPercent || 0,
199
+ });
200
+ }
201
+ }
202
+
203
+ if (result.highImpactFiles.length > 0) {
204
+ const maxImpact = Math.max(...result.highImpactFiles.map(f => f.impactPercent));
205
+ result.score = Math.min(CAPS.blastRadius, Math.round(maxImpact / 100 * CAPS.blastRadius * 2));
206
+ result.reasons.push({
207
+ type: "high_blast_radius",
208
+ severity: maxImpact > 25 ? "high" : "medium",
209
+ confidence: 0.85,
210
+ message: `High blast radius: ${maxImpact.toFixed(1)}% of codebase affected.`,
211
+ details: { maxImpactPercent: maxImpact },
212
+ });
213
+ }
214
+ } catch (_) {
215
+ // No graph available
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ function scoreInterfaceBreak(parsedDiff) {
222
+ const result = { score: 0, changes: [], reasons: [] };
223
+
224
+ for (const file of parsedDiff.files) {
225
+ // Removed exports = definite interface break
226
+ for (const exp of file.exportsRemoved) {
227
+ result.changes.push({
228
+ file: file.path,
229
+ symbol: exp.symbol,
230
+ changeType: "removed",
231
+ severity: "high",
232
+ });
233
+ result.score = Math.min(CAPS.interfaceBreak, result.score + 10);
234
+ result.reasons.push({
235
+ type: "interface_break",
236
+ severity: "high",
237
+ confidence: 0.95,
238
+ message: `Exported ${exp.kind} "${exp.symbol}" was removed.`,
239
+ details: { file: file.path, symbol: exp.symbol },
240
+ });
241
+ }
242
+
243
+ // Modified exports = potential interface break
244
+ for (const exp of file.exportsModified) {
245
+ result.changes.push({
246
+ file: file.path,
247
+ symbol: exp.symbol,
248
+ changeType: "signature_changed",
249
+ severity: "high",
250
+ });
251
+ result.score = Math.min(CAPS.interfaceBreak, result.score + 5);
252
+ result.reasons.push({
253
+ type: "interface_break",
254
+ severity: "high",
255
+ confidence: 0.8,
256
+ message: `Exported ${exp.kind || "symbol"} "${exp.symbol}" signature changed.`,
257
+ details: { file: file.path, symbol: exp.symbol },
258
+ });
259
+ }
260
+ }
261
+
262
+ return result;
263
+ }
264
+
265
+ function scoreProtectedSymbolEdit(root, parsedDiff) {
266
+ const result = { score: 0, changes: [], reasons: [] };
267
+
268
+ // Get lock-file mapping to identify protected zones
269
+ let protectedFiles = new Set();
270
+ let protectedLocks = {};
271
+ try {
272
+ const lockMap = mapLocksToFiles(root);
273
+ for (const m of lockMap) {
274
+ for (const f of m.matchedFiles) {
275
+ protectedFiles.add(f.toLowerCase());
276
+ protectedLocks[f.toLowerCase()] = m.lockText;
277
+ }
278
+ }
279
+ } catch (_) {
280
+ return result;
281
+ }
282
+
283
+ for (const file of parsedDiff.files) {
284
+ const fileNorm = file.path.replace(/\\/g, "/").toLowerCase();
285
+ const isProtected = protectedFiles.has(fileNorm) ||
286
+ [...protectedFiles].some(pf => fileNorm.endsWith("/" + pf) || pf.endsWith("/" + fileNorm));
287
+
288
+ if (!isProtected) continue;
289
+
290
+ const lockText = protectedLocks[fileNorm] || "protected zone";
291
+
292
+ for (const sym of file.symbolsTouched) {
293
+ const severity = sym.changeType === "definition_changed" ? "critical" : "high";
294
+ const score = severity === "critical" ? 12 : 8;
295
+ result.score = Math.min(CAPS.protectedSymbolEdit, result.score + score);
296
+ result.changes.push({
297
+ file: file.path,
298
+ symbol: sym.symbol,
299
+ changeType: sym.changeType,
300
+ severity,
301
+ });
302
+ result.reasons.push({
303
+ type: "protected_symbol_edit",
304
+ severity,
305
+ confidence: 0.93,
306
+ message: `Protected symbol "${sym.symbol}" was modified in locked zone.`,
307
+ details: { file: file.path, symbol: sym.symbol, lock: lockText },
308
+ });
309
+ }
310
+ }
311
+
312
+ return result;
313
+ }
314
+
315
+ function scoreDependencyDrift(parsedDiff) {
316
+ const result = { score: 0, changes: [], reasons: [] };
317
+
318
+ for (const file of parsedDiff.files) {
319
+ if (file.importsAdded.length === 0 && file.importsRemoved.length === 0) continue;
320
+
321
+ const change = {
322
+ file: file.path,
323
+ addedImports: file.importsAdded,
324
+ removedImports: file.importsRemoved,
325
+ };
326
+
327
+ // Score based on criticality
328
+ for (const imp of file.importsAdded) {
329
+ const pkg = imp.replace(/^@[^/]+\//, "").split("/")[0];
330
+ if (CRITICAL_DEPS.has(pkg) || CRITICAL_DEPS.has(imp.split("/")[0])) {
331
+ result.score = Math.min(CAPS.dependencyDrift, result.score + 5);
332
+ result.reasons.push({
333
+ type: "dependency_drift",
334
+ severity: "high",
335
+ confidence: 0.85,
336
+ message: `Critical dependency "${imp}" added.`,
337
+ details: { file: file.path, import: imp },
338
+ });
339
+ } else if (!imp.startsWith(".") && !imp.startsWith("/")) {
340
+ // External non-relative import
341
+ result.score = Math.min(CAPS.dependencyDrift, result.score + 2);
342
+ }
343
+ }
344
+
345
+ for (const imp of file.importsRemoved) {
346
+ const pkg = imp.replace(/^@[^/]+\//, "").split("/")[0];
347
+ if (CRITICAL_DEPS.has(pkg) || CRITICAL_DEPS.has(imp.split("/")[0])) {
348
+ result.score = Math.min(CAPS.dependencyDrift, result.score + 5);
349
+ result.reasons.push({
350
+ type: "dependency_drift",
351
+ severity: "high",
352
+ confidence: 0.85,
353
+ message: `Critical dependency "${imp}" removed.`,
354
+ details: { file: file.path, import: imp },
355
+ });
356
+ }
357
+ }
358
+
359
+ result.changes.push(change);
360
+ }
361
+
362
+ return result;
363
+ }
364
+
365
+ function scoreSchemaChange(parsedDiff) {
366
+ const result = { score: 0, changes: [], reasons: [] };
367
+
368
+ for (const file of parsedDiff.files) {
369
+ if (!file.isSchemaFile) continue;
370
+
371
+ // Schema file was modified
372
+ const hasDestructive = file.deletions > 0;
373
+ const severity = hasDestructive ? "critical" : "medium";
374
+ const score = hasDestructive ? 12 : 4;
375
+ result.score = Math.min(CAPS.schemaChange, result.score + score);
376
+
377
+ result.changes.push({
378
+ file: file.path,
379
+ additions: file.additions,
380
+ deletions: file.deletions,
381
+ isDestructive: hasDestructive,
382
+ });
383
+
384
+ result.reasons.push({
385
+ type: "schema_change",
386
+ severity,
387
+ confidence: 0.9,
388
+ message: hasDestructive
389
+ ? `Schema/migration file "${file.path}" has destructive changes (${file.deletions} deletions).`
390
+ : `Schema/migration file "${file.path}" modified.`,
391
+ details: { file: file.path, deletions: file.deletions },
392
+ });
393
+ }
394
+
395
+ return result;
396
+ }
397
+
398
+ function scorePublicApiImpact(parsedDiff) {
399
+ const result = { score: 0, changes: [], reasons: [] };
400
+
401
+ for (const file of parsedDiff.files) {
402
+ if (file.routeChanges.length === 0) continue;
403
+
404
+ for (const route of file.routeChanges) {
405
+ let score = 0;
406
+ let severity = "medium";
407
+
408
+ if (route.changeType === "removed") {
409
+ score = 15;
410
+ severity = "critical";
411
+ } else if (route.changeType === "modified") {
412
+ score = 10;
413
+ severity = "high";
414
+ } else {
415
+ score = 3; // added — low risk
416
+ severity = "low";
417
+ }
418
+
419
+ result.score = Math.min(CAPS.publicApiImpact, result.score + score);
420
+ result.changes.push({
421
+ file: file.path,
422
+ route: route.path,
423
+ method: route.method,
424
+ changeType: route.changeType,
425
+ severity,
426
+ });
427
+
428
+ if (severity !== "low") {
429
+ result.reasons.push({
430
+ type: "public_api_impact",
431
+ severity,
432
+ confidence: 0.88,
433
+ message: `API route ${route.method} ${route.path} ${route.changeType}.`,
434
+ details: { file: file.path, route: route.path, method: route.method },
435
+ });
436
+ }
437
+ }
438
+ }
439
+
440
+ return result;
441
+ }
442
+
443
+ function scoreTypedConstraintRelevance(description, typedLocks) {
444
+ const result = { score: 0, matchedConstraints: [] };
445
+ const descLower = description.toLowerCase();
446
+
447
+ for (const lock of typedLocks) {
448
+ const metric = (lock.metric || lock.description || lock.text || "").toLowerCase();
449
+ if (metric && descLower.includes(metric.split(" ")[0])) {
450
+ result.matchedConstraints.push({
451
+ lockId: lock.id,
452
+ constraintType: lock.constraintType,
453
+ metric: lock.metric || lock.description,
454
+ });
455
+ result.score = Math.min(CAPS.typedConstraintRelevance, result.score + 5);
456
+ }
457
+ }
458
+
459
+ return result;
460
+ }
461
+
462
+ /**
463
+ * Calculate final verdict from scored signals.
464
+ *
465
+ * @param {object} signals - All scored signals
466
+ * @param {object[]} reasons - All collected reasons
467
+ * @returns {object} { verdict, riskScore, recommendation }
468
+ */
469
+ export function calculateVerdict(signals, reasons) {
470
+ // Sum all signal scores (capped individually)
471
+ let rawScore = 0;
472
+ for (const key of Object.keys(signals)) {
473
+ rawScore += signals[key].score || 0;
474
+ }
475
+ const riskScore = Math.min(100, rawScore);
476
+
477
+ // --- Hard escalation rules (override score) ---
478
+ let hardBlock = false;
479
+ let hardBlockReason = "";
480
+
481
+ // Protected symbol removed/renamed in locked zone
482
+ const protectedCritical = reasons.filter(r =>
483
+ r.type === "protected_symbol_edit" && r.severity === "critical"
484
+ );
485
+ if (protectedCritical.length > 0) {
486
+ hardBlock = true;
487
+ hardBlockReason = "Protected symbol modified in locked zone.";
488
+ }
489
+
490
+ // Schema destructive change
491
+ const destructiveSchema = reasons.filter(r =>
492
+ r.type === "schema_change" && r.severity === "critical"
493
+ );
494
+ if (destructiveSchema.length > 0) {
495
+ hardBlock = true;
496
+ hardBlockReason = "Destructive schema/migration change detected.";
497
+ }
498
+
499
+ // Public API route removed
500
+ const apiRemoved = reasons.filter(r =>
501
+ r.type === "public_api_impact" && r.severity === "critical"
502
+ );
503
+ if (apiRemoved.length > 0) {
504
+ hardBlock = true;
505
+ hardBlockReason = "Public API route removed.";
506
+ }
507
+
508
+ // Two or more critical reasons with confidence > 0.9
509
+ const highConfCritical = reasons.filter(r =>
510
+ r.severity === "critical" && (r.confidence || 0) > 0.9
511
+ );
512
+ if (highConfCritical.length >= 2) {
513
+ hardBlock = true;
514
+ hardBlockReason = "Multiple critical issues with high confidence.";
515
+ }
516
+
517
+ // --- Determine verdict ---
518
+ let verdict;
519
+ if (hardBlock || riskScore >= 50) {
520
+ verdict = "BLOCK";
521
+ } else if (riskScore >= 25) {
522
+ verdict = "WARN";
523
+ } else {
524
+ verdict = "ALLOW";
525
+ }
526
+
527
+ // --- Recommendation ---
528
+ let recommendation;
529
+ if (verdict === "BLOCK") {
530
+ recommendation = {
531
+ action: "require_approval",
532
+ why: hardBlockReason || `Risk score ${riskScore}/100 exceeds threshold.`,
533
+ };
534
+ } else if (verdict === "WARN") {
535
+ recommendation = {
536
+ action: "review_recommended",
537
+ why: `Risk score ${riskScore}/100 — review before merging.`,
538
+ };
539
+ } else {
540
+ recommendation = {
541
+ action: "safe_to_proceed",
542
+ why: `Risk score ${riskScore}/100 — no significant issues detected.`,
543
+ };
544
+ }
545
+
546
+ return { verdict, riskScore, recommendation };
547
+ }