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 +6 -6
- package/package.json +2 -2
- package/src/cli/index.js +1 -1
- package/src/core/compliance.js +1 -1
- package/src/core/diff-analyzer.js +547 -0
- package/src/core/diff-parser.js +349 -0
- package/src/core/engine.js +12 -0
- package/src/core/patch-gateway.js +565 -0
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +87 -6
- package/src/mcp/server.js +180 -1
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-
|
|
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
|
-
**
|
|
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
|
-
##
|
|
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 (
|
|
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%** | **
|
|
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 —
|
|
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
|
|
5
|
+
"version": "5.2.0",
|
|
6
6
|
|
|
7
|
-
"description": "AI Constraint Engine
|
|
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.
|
|
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]
|
package/src/core/compliance.js
CHANGED
|
@@ -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
|
+
}
|