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
|
@@ -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
|
@@ -638,3 +638,15 @@ export {
|
|
|
638
638
|
getModules,
|
|
639
639
|
getCriticalPaths,
|
|
640
640
|
} from "./code-graph.js";
|
|
641
|
+
|
|
642
|
+
// --- Patch Gateway (v5.1) + Diff-Native Review (v5.2) ---
|
|
643
|
+
export {
|
|
644
|
+
reviewPatch,
|
|
645
|
+
reviewPatchAsync,
|
|
646
|
+
reviewPatchDiff,
|
|
647
|
+
reviewPatchDiffAsync,
|
|
648
|
+
reviewPatchUnified,
|
|
649
|
+
} from "./patch-gateway.js";
|
|
650
|
+
|
|
651
|
+
// --- Diff Parser (v5.2) ---
|
|
652
|
+
export { parseDiff as parseUnifiedDiff } from "./diff-parser.js";
|