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