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.
- 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 +7 -1
- package/src/core/patch-gateway.js +219 -0
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +55 -6
- package/src/mcp/server.js +114 -1
|
@@ -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
|
+
}
|
package/src/core/engine.js
CHANGED
|
@@ -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
|
+
}
|
package/src/dashboard/index.html
CHANGED
|
@@ -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.
|
|
92
|
+
<div class="meta">v5.2.0 — 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.
|
|
185
|
+
SpecLock v5.2.0 — Developed by Sandeep Roy — <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
|
|
186
186
|
</div>
|
|
187
187
|
|
|
188
188
|
<script>
|