speclock 5.1.0 → 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.
@@ -0,0 +1,349 @@
1
+ // ===================================================================
2
+ // SpecLock Diff Parser — Unified Diff → Structured Changes
3
+ // Parses git unified diff format into actionable change objects.
4
+ // Foundation for diff-native patch review.
5
+ //
6
+ // Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ // ===================================================================
8
+
9
+ // --- Import/export detection regexes ---
10
+
11
+ // JS/TS imports
12
+ const JS_IMPORT_FROM = /(?:import|export)\s+(?:[\s\S]*?)\s+from\s+["']([^"']+)["']/;
13
+ const JS_REQUIRE = /(?:const|let|var)\s+.*?=\s*require\s*\(\s*["']([^"']+)["']\s*\)/;
14
+ const JS_DYNAMIC_IMPORT = /import\s*\(\s*["']([^"']+)["']\s*\)/;
15
+ const JS_IMPORT_PLAIN = /^import\s+["']([^"']+)["']/;
16
+
17
+ // Python imports
18
+ const PY_IMPORT = /^import\s+([\w.]+)/;
19
+ const PY_FROM_IMPORT = /^from\s+([\w.]+)\s+import/;
20
+
21
+ // JS/TS exports
22
+ const JS_EXPORT_FUNCTION = /export\s+(?:async\s+)?function\s+(\w+)/;
23
+ const JS_EXPORT_CONST = /export\s+(?:const|let|var)\s+(\w+)/;
24
+ const JS_EXPORT_CLASS = /export\s+(?:default\s+)?class\s+(\w+)/;
25
+ const JS_EXPORT_DEFAULT = /export\s+default\s+(?:function\s+)?(\w+)?/;
26
+ const JS_NAMED_EXPORT = /export\s*\{([^}]+)\}/;
27
+
28
+ // Function/class definitions (for symbol detection)
29
+ const JS_FUNCTION_DEF = /(?:async\s+)?function\s+(\w+)\s*\(/;
30
+ const JS_ARROW_DEF = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(?/;
31
+ const JS_CLASS_METHOD = /(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/;
32
+ const PY_FUNCTION_DEF = /^def\s+(\w+)\s*\(/;
33
+ const PY_CLASS_DEF = /^class\s+(\w+)/;
34
+
35
+ // Route patterns
36
+ const EXPRESS_ROUTE = /(?:app|router)\s*\.\s*(get|post|put|patch|delete|all)\s*\(\s*["']([^"']+)["']/;
37
+ const FASTAPI_ROUTE = /@(?:app|router)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*["']([^"']+)["']/;
38
+
39
+ // Schema/migration patterns
40
+ const SCHEMA_FILE_PATTERNS = [
41
+ /migration/i, /schema/i, /model/i, /prisma/i, /\.sql$/i,
42
+ /knexfile/i, /sequelize/i, /typeorm/i, /drizzle/i,
43
+ ];
44
+
45
+ /**
46
+ * Parse a unified diff string into structured file changes.
47
+ *
48
+ * @param {string} diffText - Raw unified diff (git diff output)
49
+ * @returns {object} Parsed diff with structured changes per file
50
+ */
51
+ export function parseDiff(diffText) {
52
+ if (!diffText || typeof diffText !== "string") {
53
+ return { files: [], stats: { filesChanged: 0, additions: 0, deletions: 0, hunks: 0 } };
54
+ }
55
+
56
+ const files = [];
57
+ let totalAdditions = 0;
58
+ let totalDeletions = 0;
59
+ let totalHunks = 0;
60
+
61
+ // Split into file diffs
62
+ const fileDiffs = diffText.split(/^diff --git /m).filter(Boolean);
63
+
64
+ for (const fileDiff of fileDiffs) {
65
+ const parsed = parseFileDiff(fileDiff);
66
+ if (parsed) {
67
+ files.push(parsed);
68
+ totalAdditions += parsed.additions;
69
+ totalDeletions += parsed.deletions;
70
+ totalHunks += parsed.hunks.length;
71
+ }
72
+ }
73
+
74
+ return {
75
+ files,
76
+ stats: {
77
+ filesChanged: files.length,
78
+ additions: totalAdditions,
79
+ deletions: totalDeletions,
80
+ hunks: totalHunks,
81
+ },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Parse a single file's diff section.
87
+ */
88
+ function parseFileDiff(fileDiffText) {
89
+ // Extract file path
90
+ const pathMatch = fileDiffText.match(/a\/(.+?)\s+b\/(.+?)(?:\n|$)/);
91
+ if (!pathMatch) return null;
92
+
93
+ const filePath = pathMatch[2];
94
+ const language = detectLanguage(filePath);
95
+
96
+ // Parse hunks
97
+ const hunks = [];
98
+ const hunkRegex = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)?$/gm;
99
+ let match;
100
+
101
+ while ((match = hunkRegex.exec(fileDiffText)) !== null) {
102
+ const hunkStart = match.index;
103
+ const nextHunk = fileDiffText.indexOf("\n@@", hunkStart + 1);
104
+ const hunkEnd = nextHunk === -1 ? fileDiffText.length : nextHunk;
105
+ const hunkBody = fileDiffText.substring(hunkStart, hunkEnd);
106
+
107
+ const lines = hunkBody.split("\n").slice(1); // skip @@ header
108
+ const addedLines = [];
109
+ const removedLines = [];
110
+
111
+ for (const line of lines) {
112
+ if (line.startsWith("+") && !line.startsWith("+++")) {
113
+ addedLines.push(line.substring(1));
114
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
115
+ removedLines.push(line.substring(1));
116
+ }
117
+ }
118
+
119
+ hunks.push({
120
+ oldStart: parseInt(match[1], 10),
121
+ oldCount: parseInt(match[2] || "1", 10),
122
+ newStart: parseInt(match[3], 10),
123
+ newCount: parseInt(match[4] || "1", 10),
124
+ context: (match[5] || "").trim(),
125
+ addedLines,
126
+ removedLines,
127
+ });
128
+ }
129
+
130
+ // Count additions/deletions
131
+ let additions = 0;
132
+ let deletions = 0;
133
+ for (const hunk of hunks) {
134
+ additions += hunk.addedLines.length;
135
+ deletions += hunk.removedLines.length;
136
+ }
137
+
138
+ // Detect import changes
139
+ const importsAdded = [];
140
+ const importsRemoved = [];
141
+ for (const hunk of hunks) {
142
+ for (const line of hunk.addedLines) {
143
+ const imp = extractImport(line.trim(), language);
144
+ if (imp && !importsAdded.includes(imp)) importsAdded.push(imp);
145
+ }
146
+ for (const line of hunk.removedLines) {
147
+ const imp = extractImport(line.trim(), language);
148
+ if (imp && !importsRemoved.includes(imp)) importsRemoved.push(imp);
149
+ }
150
+ }
151
+
152
+ // Detect export changes
153
+ const exportsAdded = [];
154
+ const exportsRemoved = [];
155
+ const exportsModified = [];
156
+ for (const hunk of hunks) {
157
+ for (const line of hunk.addedLines) {
158
+ const exp = extractExport(line.trim(), language);
159
+ if (exp) {
160
+ // Check if this export was in removed lines (modified) or truly new
161
+ const wasRemoved = hunk.removedLines.some(rl => {
162
+ const re = extractExport(rl.trim(), language);
163
+ return re && re.symbol === exp.symbol;
164
+ });
165
+ if (wasRemoved) {
166
+ if (!exportsModified.find(e => e.symbol === exp.symbol)) {
167
+ exportsModified.push({ ...exp, changeType: "signature_changed" });
168
+ }
169
+ } else {
170
+ if (!exportsAdded.find(e => e.symbol === exp.symbol)) {
171
+ exportsAdded.push(exp);
172
+ }
173
+ }
174
+ }
175
+ }
176
+ for (const line of hunk.removedLines) {
177
+ const exp = extractExport(line.trim(), language);
178
+ if (exp) {
179
+ const wasAdded = hunk.addedLines.some(al => {
180
+ const ae = extractExport(al.trim(), language);
181
+ return ae && ae.symbol === exp.symbol;
182
+ });
183
+ if (!wasAdded) {
184
+ if (!exportsRemoved.find(e => e.symbol === exp.symbol)) {
185
+ exportsRemoved.push({ ...exp, changeType: "removed" });
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ // Detect symbols touched (functions/classes modified)
193
+ const symbolsTouched = [];
194
+ for (const hunk of hunks) {
195
+ // Context line from @@ header often shows the function scope
196
+ if (hunk.context) {
197
+ const sym = extractSymbol(hunk.context, language);
198
+ if (sym && !symbolsTouched.find(s => s.symbol === sym.symbol)) {
199
+ symbolsTouched.push({ ...sym, changeType: "body_modified" });
200
+ }
201
+ }
202
+ // Also check removed/added lines for function definitions
203
+ for (const line of [...hunk.removedLines, ...hunk.addedLines]) {
204
+ const sym = extractSymbol(line.trim(), language);
205
+ if (sym && !symbolsTouched.find(s => s.symbol === sym.symbol)) {
206
+ symbolsTouched.push({ ...sym, changeType: "definition_changed" });
207
+ }
208
+ }
209
+ }
210
+
211
+ // Detect route changes
212
+ const routeChanges = [];
213
+ for (const hunk of hunks) {
214
+ for (const line of hunk.addedLines) {
215
+ const route = extractRoute(line.trim());
216
+ if (route) {
217
+ const wasRemoved = hunk.removedLines.some(rl => extractRoute(rl.trim())?.path === route.path);
218
+ routeChanges.push({ ...route, changeType: wasRemoved ? "modified" : "added" });
219
+ }
220
+ }
221
+ for (const line of hunk.removedLines) {
222
+ const route = extractRoute(line.trim());
223
+ if (route) {
224
+ const wasAdded = hunk.addedLines.some(al => extractRoute(al.trim())?.path === route.path);
225
+ if (!wasAdded) routeChanges.push({ ...route, changeType: "removed" });
226
+ }
227
+ }
228
+ }
229
+
230
+ // Detect if this is a schema/migration file
231
+ const isSchemaFile = SCHEMA_FILE_PATTERNS.some(p => p.test(filePath));
232
+
233
+ return {
234
+ path: filePath,
235
+ language,
236
+ additions,
237
+ deletions,
238
+ hunks,
239
+ importsAdded,
240
+ importsRemoved,
241
+ exportsAdded,
242
+ exportsRemoved,
243
+ exportsModified,
244
+ symbolsTouched,
245
+ routeChanges,
246
+ isSchemaFile,
247
+ };
248
+ }
249
+
250
+ // --- Extraction helpers ---
251
+
252
+ function extractImport(line, language) {
253
+ if (language === "python") {
254
+ let m = line.match(PY_IMPORT);
255
+ if (m) return m[1];
256
+ m = line.match(PY_FROM_IMPORT);
257
+ if (m) return m[1];
258
+ return null;
259
+ }
260
+
261
+ // JS/TS
262
+ let m = line.match(JS_IMPORT_FROM);
263
+ if (m) return m[1];
264
+ m = line.match(JS_REQUIRE);
265
+ if (m) return m[1];
266
+ m = line.match(JS_DYNAMIC_IMPORT);
267
+ if (m) return m[1];
268
+ m = line.match(JS_IMPORT_PLAIN);
269
+ if (m) return m[1];
270
+ return null;
271
+ }
272
+
273
+ function extractExport(line, language) {
274
+ if (language === "python") {
275
+ // Python doesn't have explicit exports in the same way
276
+ // but we detect class/function definitions at module level
277
+ let m = line.match(PY_FUNCTION_DEF);
278
+ if (m && !m[1].startsWith("_")) return { symbol: m[1], kind: "function" };
279
+ m = line.match(PY_CLASS_DEF);
280
+ if (m) return { symbol: m[1], kind: "class" };
281
+ return null;
282
+ }
283
+
284
+ // JS/TS
285
+ let m = line.match(JS_EXPORT_FUNCTION);
286
+ if (m) return { symbol: m[1], kind: "function" };
287
+ m = line.match(JS_EXPORT_CONST);
288
+ if (m) return { symbol: m[1], kind: "const" };
289
+ m = line.match(JS_EXPORT_CLASS);
290
+ if (m) return { symbol: m[1], kind: "class" };
291
+ m = line.match(JS_EXPORT_DEFAULT);
292
+ if (m && m[1]) return { symbol: m[1], kind: "default" };
293
+ // Named exports: export { a, b, c }
294
+ m = line.match(JS_NAMED_EXPORT);
295
+ if (m) {
296
+ // Return just the first symbol for simplicity
297
+ const symbols = m[1].split(",").map(s => s.trim().split(/\s+as\s+/)[0].trim());
298
+ if (symbols[0]) return { symbol: symbols[0], kind: "named" };
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function extractSymbol(line, language) {
304
+ if (language === "python") {
305
+ let m = line.match(PY_FUNCTION_DEF);
306
+ if (m) return { symbol: m[1], kind: "function" };
307
+ m = line.match(PY_CLASS_DEF);
308
+ if (m) return { symbol: m[1], kind: "class" };
309
+ return null;
310
+ }
311
+
312
+ // JS/TS
313
+ let m = line.match(JS_EXPORT_FUNCTION);
314
+ if (m) return { symbol: m[1], kind: "function" };
315
+ m = line.match(JS_FUNCTION_DEF);
316
+ if (m) return { symbol: m[1], kind: "function" };
317
+ m = line.match(JS_EXPORT_CLASS);
318
+ if (m) return { symbol: m[1], kind: "class" };
319
+ m = line.match(JS_CLASS_METHOD);
320
+ if (m && !["if", "for", "while", "switch", "catch", "else"].includes(m[1])) {
321
+ return { symbol: m[1], kind: "method" };
322
+ }
323
+ return null;
324
+ }
325
+
326
+ function extractRoute(line) {
327
+ let m = line.match(EXPRESS_ROUTE);
328
+ if (m) return { method: m[1].toUpperCase(), path: m[2] };
329
+ m = line.match(FASTAPI_ROUTE);
330
+ if (m) return { method: m[1].toUpperCase(), path: m[2] };
331
+ return null;
332
+ }
333
+
334
+ function detectLanguage(filePath) {
335
+ const ext = filePath.split(".").pop()?.toLowerCase();
336
+ switch (ext) {
337
+ case "js": case "jsx": case "mjs": case "cjs": return "javascript";
338
+ case "ts": case "tsx": return "typescript";
339
+ case "py": case "pyw": return "python";
340
+ case "rb": return "ruby";
341
+ case "go": return "go";
342
+ case "rs": return "rust";
343
+ case "java": return "java";
344
+ case "sql": return "sql";
345
+ case "json": return "json";
346
+ case "yaml": case "yml": return "yaml";
347
+ default: return "unknown";
348
+ }
349
+ }
@@ -639,8 +639,14 @@ export {
639
639
  getCriticalPaths,
640
640
  } from "./code-graph.js";
641
641
 
642
- // --- Patch Gateway (v5.1) ---
642
+ // --- Patch Gateway (v5.1) + Diff-Native Review (v5.2) ---
643
643
  export {
644
644
  reviewPatch,
645
645
  reviewPatchAsync,
646
+ reviewPatchDiff,
647
+ reviewPatchDiffAsync,
648
+ reviewPatchUnified,
646
649
  } from "./patch-gateway.js";
650
+
651
+ // --- Diff Parser (v5.2) ---
652
+ export { parseDiff as parseUnifiedDiff } from "./diff-parser.js";
@@ -344,3 +344,222 @@ function buildSummary(verdict, riskScore, reasons, files, blastDetails, lockFile
344
344
 
345
345
  return parts.join(". ") + ".";
346
346
  }
347
+
348
+ // ===================================================================
349
+ // DIFF-NATIVE REVIEW (v5.2) — Actual patch analysis
350
+ // ===================================================================
351
+
352
+ import { parseDiff } from "./diff-parser.js";
353
+ import { analyzeDiff, calculateVerdict } from "./diff-analyzer.js";
354
+
355
+ /**
356
+ * Review a proposed change using actual diff content.
357
+ * Combines diff-level signal extraction with project constraints.
358
+ *
359
+ * @param {string} root - Project root
360
+ * @param {object} opts
361
+ * @param {string} opts.description - What the change does
362
+ * @param {string[]} [opts.files] - Files being changed
363
+ * @param {string} opts.diff - Raw unified diff (git diff output)
364
+ * @param {object} [opts.options] - Analysis options
365
+ * @returns {object} Diff-native review result
366
+ */
367
+ export function reviewPatchDiff(root, { description, files = [], diff, options = {} }) {
368
+ if (!description || typeof description !== "string" || !description.trim()) {
369
+ return {
370
+ verdict: "ERROR",
371
+ riskScore: 0,
372
+ reviewMode: "diff-native",
373
+ error: "description is required",
374
+ signals: {},
375
+ reasons: [],
376
+ summary: "No change description provided.",
377
+ };
378
+ }
379
+
380
+ if (!diff || typeof diff !== "string" || !diff.trim()) {
381
+ return {
382
+ verdict: "ERROR",
383
+ riskScore: 0,
384
+ reviewMode: "diff-native",
385
+ error: "diff is required (provide git diff output)",
386
+ signals: {},
387
+ reasons: [],
388
+ summary: "No diff content provided.",
389
+ };
390
+ }
391
+
392
+ // Parse the diff
393
+ const parsedDiff = parseDiff(diff);
394
+
395
+ // If files not provided, extract from parsed diff
396
+ if (files.length === 0 && parsedDiff.files.length > 0) {
397
+ files = parsedDiff.files.map(f => f.path);
398
+ }
399
+
400
+ // Run all signal analyzers
401
+ const { signals, reasons } = analyzeDiff(root, parsedDiff, description, options);
402
+
403
+ // Calculate verdict from signals
404
+ const { verdict, riskScore, recommendation } = calculateVerdict(signals, reasons);
405
+
406
+ // Build summary
407
+ const summaryParts = [`${verdict} (risk: ${riskScore}/100)`];
408
+ const criticalReasons = reasons.filter(r => r.severity === "critical");
409
+ const highReasons = reasons.filter(r => r.severity === "high");
410
+ if (criticalReasons.length > 0) summaryParts.push(`${criticalReasons.length} critical issue(s)`);
411
+ if (highReasons.length > 0) summaryParts.push(`${highReasons.length} high-severity issue(s)`);
412
+ if (parsedDiff.stats.filesChanged > 0) {
413
+ summaryParts.push(`${parsedDiff.stats.filesChanged} file(s), +${parsedDiff.stats.additions}/-${parsedDiff.stats.deletions}`);
414
+ }
415
+
416
+ return {
417
+ verdict,
418
+ riskScore,
419
+ reviewMode: "diff-native",
420
+ description,
421
+ files,
422
+ signals,
423
+ reasons,
424
+ parsedDiff: parsedDiff.stats,
425
+ recommendation,
426
+ summary: summaryParts.join(". ") + ".",
427
+ api_version: "v2",
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Async diff review — adds LLM conflict checking for ambiguous cases.
433
+ */
434
+ export async function reviewPatchDiffAsync(root, opts) {
435
+ const result = reviewPatchDiff(root, opts);
436
+
437
+ if (result.verdict === "ERROR" || result.verdict === "BLOCK") {
438
+ result.source = result.verdict === "BLOCK" ? "diff-native" : "error";
439
+ return result;
440
+ }
441
+
442
+ // For WARN / ALLOW, try LLM enhancement
443
+ try {
444
+ const { llmCheckConflict } = await import("./llm-checker.js");
445
+ const brain = readBrain(root);
446
+ const activeLocks = (brain?.specLock?.items || []).filter(l => l.active !== false && !l.constraintType);
447
+
448
+ if (activeLocks.length > 0) {
449
+ const llmResult = await llmCheckConflict(root, opts.description, activeLocks);
450
+ if (llmResult && llmResult.hasConflict) {
451
+ for (const lc of (llmResult.conflictingLocks || [])) {
452
+ const confidence = (lc.confidence || 50) / 100;
453
+ result.signals.llmConflict.used = true;
454
+ result.signals.llmConflict.score = Math.min(CAPS_LLM, Math.round(confidence * 10));
455
+ result.reasons.push({
456
+ type: "llm_conflict",
457
+ severity: confidence >= 0.7 ? "critical" : "high",
458
+ confidence,
459
+ message: `LLM detected conflict with: "${lc.text}"`,
460
+ details: { lockId: lc.id, lockText: lc.text },
461
+ });
462
+ }
463
+ // Recalculate verdict
464
+ const recalc = calculateVerdict(result.signals, result.reasons);
465
+ result.verdict = recalc.verdict;
466
+ result.riskScore = recalc.riskScore;
467
+ result.recommendation = recalc.recommendation;
468
+ }
469
+ }
470
+ result.source = "diff-native+llm";
471
+ } catch (_) {
472
+ result.source = "diff-native";
473
+ }
474
+
475
+ return result;
476
+ }
477
+
478
+ const CAPS_LLM = 10;
479
+
480
+ /**
481
+ * Unified review — runs both intent review (v5.1) and diff review (v5.2),
482
+ * then merges results. Takes the stronger verdict.
483
+ *
484
+ * @param {string} root - Project root
485
+ * @param {object} opts - Same as reviewPatchDiff but diff is optional
486
+ * @returns {object} Unified review result
487
+ */
488
+ export function reviewPatchUnified(root, opts) {
489
+ const hasDiff = opts.diff && typeof opts.diff === "string" && opts.diff.trim();
490
+
491
+ // Always run intent review (v5.1)
492
+ const intentResult = reviewPatch(root, {
493
+ description: opts.description,
494
+ files: opts.files || [],
495
+ includeGraph: true,
496
+ });
497
+
498
+ if (!hasDiff) {
499
+ // No diff available — return intent review only
500
+ return {
501
+ ...intentResult,
502
+ reviewMode: "intent-only",
503
+ source: "v5.1-intent",
504
+ };
505
+ }
506
+
507
+ // Run diff review (v5.2)
508
+ const diffResult = reviewPatchDiff(root, opts);
509
+
510
+ if (diffResult.verdict === "ERROR") {
511
+ // Diff parsing failed — fallback to intent only
512
+ return {
513
+ ...intentResult,
514
+ reviewMode: "intent-only",
515
+ source: "v5.1-intent (diff parse failed)",
516
+ };
517
+ }
518
+
519
+ // Merge results — weighted: intent 35%, diff 65%
520
+ const intentWeight = 0.35;
521
+ const diffWeight = 0.65;
522
+ const mergedRisk = Math.min(100, Math.round(
523
+ intentResult.riskScore * intentWeight + diffResult.riskScore * diffWeight
524
+ ));
525
+
526
+ // Take stronger verdict
527
+ const verdictRank = { ALLOW: 0, WARN: 1, BLOCK: 2 };
528
+ const finalVerdict = verdictRank[diffResult.verdict] >= verdictRank[intentResult.verdict]
529
+ ? diffResult.verdict
530
+ : intentResult.verdict;
531
+
532
+ // Merge reasons (deduplicate by type+lockId)
533
+ const mergedReasons = [...diffResult.reasons];
534
+ for (const ir of intentResult.reasons) {
535
+ const exists = mergedReasons.find(r =>
536
+ r.type === ir.type && r.details?.lockId === ir.lockId
537
+ );
538
+ if (!exists) {
539
+ mergedReasons.push({
540
+ ...ir,
541
+ confidence: typeof ir.confidence === "number" && ir.confidence > 1
542
+ ? ir.confidence / 100 : ir.confidence,
543
+ });
544
+ }
545
+ }
546
+
547
+ return {
548
+ verdict: finalVerdict,
549
+ riskScore: mergedRisk,
550
+ reviewMode: "unified",
551
+ description: opts.description,
552
+ files: diffResult.files,
553
+ signals: diffResult.signals,
554
+ reasons: mergedReasons,
555
+ parsedDiff: diffResult.parsedDiff,
556
+ blastRadius: intentResult.blastRadius,
557
+ recommendation: diffResult.recommendation,
558
+ summary: `${finalVerdict} (risk: ${mergedRisk}/100). Intent: ${intentResult.verdict}(${intentResult.riskScore}). Diff: ${diffResult.verdict}(${diffResult.riskScore}).`,
559
+ intentVerdict: intentResult.verdict,
560
+ intentRisk: intentResult.riskScore,
561
+ diffVerdict: diffResult.verdict,
562
+ diffRisk: diffResult.riskScore,
563
+ api_version: "v2",
564
+ };
565
+ }
@@ -89,7 +89,7 @@
89
89
  <div class="header">
90
90
  <div>
91
91
  <h1><span>SpecLock</span> Dashboard</h1>
92
- <div class="meta">v5.1.0 &mdash; AI Constraint Engine</div>
92
+ <div class="meta">v5.2.0 &mdash; AI Constraint Engine</div>
93
93
  </div>
94
94
  <div style="display:flex;align-items:center;gap:12px;">
95
95
  <span id="health-badge" class="status-badge healthy">Loading...</span>
@@ -182,7 +182,7 @@
182
182
  </div>
183
183
 
184
184
  <div style="text-align:center;padding:24px;color:var(--muted);font-size:12px;">
185
- SpecLock v5.1.0 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
185
+ SpecLock v5.2.0 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
186
186
  </div>
187
187
 
188
188
  <script>