speclock 5.1.0 → 5.2.1
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 +120 -15
- 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 +558 -0
- package/src/core/diff-parser.js +349 -0
- package/src/core/engine.js +7 -1
- package/src/core/patch-gateway.js +219 -0
- package/src/core/semantics.js +102 -5
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +104 -40
- package/src/mcp/server.js +114 -1
|
@@ -0,0 +1,558 @@
|
|
|
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: 45,
|
|
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
|
+
// Semantic conflict at HIGH confidence (>=0.7) should hard-block
|
|
518
|
+
// even if other signals are absent — the engine is certain this
|
|
519
|
+
// action violates a lock.
|
|
520
|
+
const highConfSemantic = reasons.filter(r =>
|
|
521
|
+
r.type === "semantic_conflict" && (r.confidence || 0) >= 0.7
|
|
522
|
+
);
|
|
523
|
+
if (highConfSemantic.length > 0) {
|
|
524
|
+
hardBlock = true;
|
|
525
|
+
hardBlockReason = `High-confidence semantic conflict: ${highConfSemantic[0].message || "action violates active lock"}.`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- Determine verdict ---
|
|
529
|
+
let verdict;
|
|
530
|
+
if (hardBlock || riskScore >= 50) {
|
|
531
|
+
verdict = "BLOCK";
|
|
532
|
+
} else if (riskScore >= 25) {
|
|
533
|
+
verdict = "WARN";
|
|
534
|
+
} else {
|
|
535
|
+
verdict = "ALLOW";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- Recommendation ---
|
|
539
|
+
let recommendation;
|
|
540
|
+
if (verdict === "BLOCK") {
|
|
541
|
+
recommendation = {
|
|
542
|
+
action: "require_approval",
|
|
543
|
+
why: hardBlockReason || `Risk score ${riskScore}/100 exceeds threshold.`,
|
|
544
|
+
};
|
|
545
|
+
} else if (verdict === "WARN") {
|
|
546
|
+
recommendation = {
|
|
547
|
+
action: "review_recommended",
|
|
548
|
+
why: `Risk score ${riskScore}/100 — review before merging.`,
|
|
549
|
+
};
|
|
550
|
+
} else {
|
|
551
|
+
recommendation = {
|
|
552
|
+
action: "safe_to_proceed",
|
|
553
|
+
why: `Risk score ${riskScore}/100 — no significant issues detected.`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { verdict, riskScore, recommendation };
|
|
558
|
+
}
|