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.
@@ -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
+ }