@synchronized-studio/cmsassets-agent 0.2.0 → 0.3.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,461 @@
1
+ import {
2
+ buildCmsOptions,
3
+ findInjectionPoints,
4
+ getTransformFunctionName
5
+ } from "./chunk-SHAT7M2R.js";
6
+ import {
7
+ chatCompletion,
8
+ isOpenAiError
9
+ } from "./chunk-E74TGIFQ.js";
10
+
11
+ // src/verifier/aiReview.ts
12
+ import { readFileSync as readFileSync2, writeFileSync } from "fs";
13
+ import { join as join2 } from "path";
14
+ import consola from "consola";
15
+ import pc from "picocolors";
16
+
17
+ // src/patcher/diffValidator.ts
18
+ var ALLOWED_ADD_PATTERNS = [
19
+ /import\s+\{[^}]*transform/i,
20
+ /response-transformer/,
21
+ /transformCmsAssetUrls/,
22
+ /transformPrismicAssetUrls/,
23
+ /transformContentfulAssetUrls/,
24
+ /transformSanityAssetUrls/,
25
+ /transformShopifyAssetUrls/,
26
+ /transformCloudinaryAssetUrls/,
27
+ /transformImgixAssetUrls/,
28
+ /transformGenericAssetUrls/
29
+ ];
30
+ function validateDiff(diff, allowedFiles) {
31
+ const errors = [];
32
+ const lines = diff.split("\n");
33
+ const fileHeaders = lines.filter((l) => l.startsWith("--- ") || l.startsWith("+++ "));
34
+ for (const header of fileHeaders) {
35
+ const filePath = header.replace(/^[+-]{3}\s+[ab]\//, "").trim();
36
+ if (filePath === "/dev/null") continue;
37
+ if (!allowedFiles.some((f) => filePath.endsWith(f) || f.endsWith(filePath))) {
38
+ errors.push(`Diff modifies disallowed file: ${filePath}`);
39
+ }
40
+ }
41
+ const addedLines = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++"));
42
+ const removedLines = lines.filter((l) => l.startsWith("-") && !l.startsWith("---"));
43
+ for (const added of addedLines) {
44
+ const lineContent = added.substring(1).trim();
45
+ if (!lineContent) continue;
46
+ const isAllowed = ALLOWED_ADD_PATTERNS.some((p) => p.test(lineContent));
47
+ const hasMatchingRemoval = removedLines.some((r) => {
48
+ const removedContent = r.substring(1).trim();
49
+ return lineContent.includes(removedContent) || removedContent.includes(lineContent.replace(/transform\w+AssetUrls\([^)]+\)/, ""));
50
+ });
51
+ if (!isAllowed && !hasMatchingRemoval) {
52
+ if (/^[{}\[\](),;\s]*$/.test(lineContent)) continue;
53
+ if (/^},?\s*\{?\s*(repository|spaceId|projectId|storeDomain|cloudName|imgixDomain|originUrl)\s*:/.test(lineContent)) continue;
54
+ if (/^\}\s*\)\s*;?\s*$/.test(lineContent)) continue;
55
+ if (/^\w+:\s*['"][^'"]+['"]\s*,?\s*\}?\)?\s*$/.test(lineContent)) continue;
56
+ errors.push(`Suspicious added line: ${lineContent.substring(0, 80)}`);
57
+ }
58
+ }
59
+ if (addedLines.length > 20) {
60
+ errors.push(`Too many additions (${addedLines.length}), expected <= 20`);
61
+ }
62
+ if (removedLines.length > 10) {
63
+ errors.push(`Too many removals (${removedLines.length}), expected <= 10`);
64
+ }
65
+ return { valid: errors.length === 0, errors };
66
+ }
67
+
68
+ // src/verifier/rescan.ts
69
+ import { readFileSync } from "fs";
70
+ import { join, relative } from "path";
71
+ function rescanFile(root, filePath, framework, cms) {
72
+ const absPath = join(root, filePath);
73
+ let content;
74
+ try {
75
+ content = readFileSync(absPath, "utf-8");
76
+ } catch {
77
+ return [];
78
+ }
79
+ const hasTransformer = content.includes("@synchronized-studio/response-transformer");
80
+ if (!hasTransformer) {
81
+ return findInjectionPoints(root, framework, cms).filter((c) => c.filePath === relative(root, absPath) || c.filePath === filePath);
82
+ }
83
+ return findUnwrappedCmsCalls(content, filePath, cms);
84
+ }
85
+ var CMS_CALL_PATTERNS = [
86
+ /client\.\w+\s*\(/g,
87
+ /\$prismic\.\w+\s*\(/g,
88
+ /\$contentful\.\w+\s*\(/g,
89
+ /\$sanity\.\w+\s*\(/g,
90
+ /usePrismic\(\)/g,
91
+ /createClient\(\)/g,
92
+ /\$fetch\s*\(\s*['"`]\/api\//g
93
+ ];
94
+ var TRANSFORM_WRAPPERS = [
95
+ "transformPrismicAssetUrls",
96
+ "transformContentfulAssetUrls",
97
+ "transformSanityAssetUrls",
98
+ "transformShopifyAssetUrls",
99
+ "transformCloudinaryAssetUrls",
100
+ "transformImgixAssetUrls",
101
+ "transformGenericAssetUrls",
102
+ "transformCmsAssetUrls"
103
+ ];
104
+ function findEnclosingFunctionRange(lines, lineIdx) {
105
+ let start = lineIdx;
106
+ let braceDepth = 0;
107
+ for (let i = lineIdx; i >= 0; i--) {
108
+ for (const c of lines[i]) {
109
+ if (c === "}") braceDepth++;
110
+ else if (c === "{") braceDepth--;
111
+ }
112
+ if (/(?:async\s+)?(?:function\b|\w+\s*\(|=>\s*\{)/.test(lines[i]) && braceDepth <= 0) {
113
+ start = i;
114
+ break;
115
+ }
116
+ }
117
+ braceDepth = 0;
118
+ let end = lineIdx;
119
+ for (let i = start; i < lines.length; i++) {
120
+ for (const c of lines[i]) {
121
+ if (c === "{") braceDepth++;
122
+ else if (c === "}") braceDepth--;
123
+ }
124
+ if (braceDepth === 0 && i > start) {
125
+ end = i;
126
+ break;
127
+ }
128
+ }
129
+ return { start, end };
130
+ }
131
+ function findUnwrappedCmsCalls(content, filePath, cms) {
132
+ const lines = content.split("\n");
133
+ const candidates = [];
134
+ for (let i = 0; i < lines.length; i++) {
135
+ const line = lines[i];
136
+ const trimmed = line.trim();
137
+ if (trimmed.startsWith("import ") || trimmed.startsWith("//") || trimmed.startsWith("*")) {
138
+ continue;
139
+ }
140
+ const hasCmsCall = CMS_CALL_PATTERNS.some((p) => {
141
+ p.lastIndex = 0;
142
+ return p.test(trimmed);
143
+ });
144
+ if (!hasCmsCall) continue;
145
+ const isWrappedInline = TRANSFORM_WRAPPERS.some((fn) => trimmed.includes(fn));
146
+ if (isWrappedInline) continue;
147
+ const fnRange = findEnclosingFunctionRange(lines, i);
148
+ const fnBody = lines.slice(fnRange.start, fnRange.end + 1).join("\n");
149
+ const fnHasTransform = TRANSFORM_WRAPPERS.some((fn) => fnBody.includes(fn));
150
+ if (fnHasTransform) continue;
151
+ const simpleAssign = trimmed.match(/(?:const|let)\s+(\w+)\s*=/);
152
+ const destructAssign = trimmed.match(/(?:const|let)\s+\{([^}]+)\}\s*=/);
153
+ const varNames = [];
154
+ if (simpleAssign) varNames.push(simpleAssign[1]);
155
+ if (destructAssign) {
156
+ varNames.push(...destructAssign[1].split(",").map((s) => s.trim().split(":")[0].trim()).filter(Boolean));
157
+ }
158
+ if (varNames.length > 0) {
159
+ const ahead = lines.slice(i + 1, Math.min(lines.length, i + 40)).join("\n");
160
+ const isAnyVarWrapped = varNames.some(
161
+ (varName) => TRANSFORM_WRAPPERS.some(
162
+ (fn) => ahead.includes(`${fn}(${varName}`) || ahead.includes(`${fn}( ${varName}`) || new RegExp(`${fn}\\([\\s\\S]{0,300}\\b${varName}\\b`).test(ahead)
163
+ )
164
+ );
165
+ if (isAnyVarWrapped) continue;
166
+ }
167
+ const contextSlice = lines.slice(Math.max(0, i - 10), Math.min(lines.length, i + 15)).join("\n");
168
+ if (/transform\s*:/.test(contextSlice)) continue;
169
+ candidates.push({
170
+ filePath,
171
+ line: i + 1,
172
+ type: "return",
173
+ score: 70,
174
+ confidence: "medium",
175
+ targetCode: trimmed,
176
+ context: lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join("\n"),
177
+ reasons: ["CMS SDK call without transform wrapper (post-patch rescan)"]
178
+ });
179
+ }
180
+ return candidates;
181
+ }
182
+
183
+ // src/verifier/aiReview.ts
184
+ async function aiReviewAll(root, patchedFiles, plan, opts = {}) {
185
+ const startTime = Date.now();
186
+ const maxIterations = opts.maxIterations ?? 3;
187
+ const model = opts.model ?? "gpt-4o";
188
+ const results = [];
189
+ let totalTokens = 0;
190
+ const uniqueFiles = [...new Set(patchedFiles)];
191
+ for (const filePath of uniqueFiles) {
192
+ consola.info(`${pc.cyan("AI review:")} ${filePath}`);
193
+ const result = await reviewFileLoop(root, filePath, plan, {
194
+ maxIterations,
195
+ model
196
+ });
197
+ totalTokens += result.tokensUsed;
198
+ results.push(result.fileResult);
199
+ const icon = result.fileResult.passed ? pc.green("\u2713") : pc.red("\u2717");
200
+ const detail = result.fileResult.fixApplied ? pc.dim("(auto-fixed)") : "";
201
+ consola.info(` ${icon} ${filePath} ${detail}`);
202
+ if (!result.fileResult.passed) {
203
+ for (const issue of result.fileResult.issues) {
204
+ consola.info(` ${pc.yellow("\u26A0")} ${issue}`);
205
+ }
206
+ }
207
+ }
208
+ const filesPassed = results.filter((r) => r.passed).length;
209
+ const filesFixed = results.filter((r) => r.fixApplied).length;
210
+ const filesFailed = results.filter((r) => !r.passed).length;
211
+ return {
212
+ schemaVersion: "1.0",
213
+ filesReviewed: results.length,
214
+ filesPassed,
215
+ filesFixed,
216
+ filesFailed,
217
+ results,
218
+ totalDurationMs: Date.now() - startTime,
219
+ tokensUsed: totalTokens
220
+ };
221
+ }
222
+ async function reviewFileLoop(root, filePath, plan, opts) {
223
+ let tokensUsed = 0;
224
+ let fixAttempted = false;
225
+ let fixApplied = false;
226
+ for (let iter = 0; iter < opts.maxIterations; iter++) {
227
+ const remaining = rescanFile(
228
+ root,
229
+ filePath,
230
+ plan.scan.framework.name,
231
+ plan.scan.cms
232
+ );
233
+ const rescanIssues = remaining.map(
234
+ (c) => `Line ${c.line}: ${c.targetCode.substring(0, 80)}`
235
+ );
236
+ const absPath = join2(root, filePath);
237
+ let content;
238
+ try {
239
+ content = readFileSync2(absPath, "utf-8");
240
+ } catch {
241
+ return {
242
+ fileResult: {
243
+ filePath,
244
+ passed: false,
245
+ issues: ["Cannot read file"],
246
+ fixAttempted,
247
+ fixApplied,
248
+ iterations: iter + 1
249
+ },
250
+ tokensUsed
251
+ };
252
+ }
253
+ const reviewResult = await aiSemanticReview(content, filePath, plan, opts.model, rescanIssues);
254
+ tokensUsed += reviewResult.tokensUsed;
255
+ if (reviewResult.passed) {
256
+ return {
257
+ fileResult: {
258
+ filePath,
259
+ passed: true,
260
+ issues: [],
261
+ fixAttempted,
262
+ fixApplied,
263
+ iterations: iter + 1
264
+ },
265
+ tokensUsed
266
+ };
267
+ }
268
+ if (!reviewResult.diff) {
269
+ return {
270
+ fileResult: {
271
+ filePath,
272
+ passed: false,
273
+ issues: reviewResult.issues,
274
+ fixAttempted,
275
+ fixApplied,
276
+ iterations: iter + 1
277
+ },
278
+ tokensUsed
279
+ };
280
+ }
281
+ fixAttempted = true;
282
+ const applied = applyAiDiff(root, filePath, content, reviewResult.diff);
283
+ if (applied) {
284
+ fixApplied = true;
285
+ consola.info(` ${pc.blue("\u21BB")} Applied AI fix (iteration ${iter + 1})`);
286
+ } else {
287
+ return {
288
+ fileResult: {
289
+ filePath,
290
+ passed: false,
291
+ issues: [...reviewResult.issues, "AI fix could not be applied"],
292
+ fixAttempted,
293
+ fixApplied,
294
+ iterations: iter + 1
295
+ },
296
+ tokensUsed
297
+ };
298
+ }
299
+ }
300
+ const finalRemaining = rescanFile(root, filePath, plan.scan.framework.name, plan.scan.cms);
301
+ const passed = finalRemaining.length === 0;
302
+ return {
303
+ fileResult: {
304
+ filePath,
305
+ passed,
306
+ issues: passed ? [] : finalRemaining.map((c) => `Line ${c.line}: unwrapped CMS call \u2014 ${c.targetCode.substring(0, 80)}`),
307
+ fixAttempted,
308
+ fixApplied,
309
+ iterations: opts.maxIterations
310
+ },
311
+ tokensUsed
312
+ };
313
+ }
314
+ async function aiSemanticReview(content, filePath, plan, model, rescanIssues) {
315
+ const fn = getTransformFunctionName(plan.scan.cms.type);
316
+ const opts = buildCmsOptions(plan.scan.cms.type, plan.scan.cms.params);
317
+ const rescanSection = rescanIssues.length > 0 ? `
318
+ Rule-based rescan found these potentially unwrapped CMS calls:
319
+ ${rescanIssues.map((i) => `- ${i}`).join("\n")}
320
+ ` : "";
321
+ const prompt = `You are verifying that CMS asset URL transformations were correctly applied to a source file, AND that the file is still structurally correct.
322
+
323
+ CMS: ${plan.scan.cms.type}
324
+ Transform function: ${fn}
325
+ Expected options: ${opts}
326
+ Framework: ${plan.scan.framework.name}
327
+ ${rescanSection}
328
+ File (${filePath}):
329
+ \`\`\`
330
+ ${content}
331
+ \`\`\`
332
+
333
+ CHECK THE FOLLOWING:
334
+
335
+ A. Transform correctness:
336
+ 1. Is the import \`import { ${fn} } from '@synchronized-studio/response-transformer'\` present?
337
+ 2. For functions that RETURN CMS data objects/arrays, is the return wrapped with ${fn}()?
338
+ - Wrapping at the return level is CORRECT and SUFFICIENT.
339
+ - \`return ${fn}({ model: document }, ${opts})\` is correct.
340
+ - \`return ${fn}(data, ${opts})\` is correct.
341
+ - Do NOT flag intermediate CMS SDK calls ($prismic, $contentful, client.get) as unwrapped
342
+ when the function's return is already wrapped.
343
+ 3. Are the options (${opts}) correct in every ${fn}() call?
344
+
345
+ B. Code integrity (CRITICAL \u2014 the patch must not break existing code):
346
+ 4. Are all functions that existed before still defined and accessible? (e.g. if a function like getPage, fetchData, etc. was defined, it must still be there)
347
+ 5. Are there any obvious undefined references \u2014 calls to functions or variables that don't exist in the file?
348
+ 6. Does the code structure look intact \u2014 no truncated functions, missing closing brackets, orphaned statements?
349
+ 7. Are all exports still present and correct?
350
+ 8. CRITICAL: If a return value is an object containing FUNCTION references (like \`{ getPage, generateMetadata }\`),
351
+ wrapping it with ${fn}() will BREAK it because the transform uses JSON.stringify which destroys functions.
352
+ The transform should ONLY wrap CMS DATA returns (objects/arrays with data properties), NOT factory/API returns
353
+ that provide callable functions. If you see this pattern, report it as an issue and provide a diff that REMOVES
354
+ the incorrect wrapping.
355
+
356
+ IMPORTANT \u2014 do NOT flag any of these as transform issues:
357
+ - Functions that do NOT return a value (e.g. they only commit to Vuex store) \u2014 these are FINE as-is
358
+ - Functions that return simple strings, URLs, booleans, or "#" \u2014 these are NOT CMS data
359
+ - Error returns like \`return err\`, \`return []\`, \`return undefined\` \u2014 skip these
360
+ - Functions that return \`dispatch(...)\` or delegate to another action \u2014 the target action handles the transform
361
+ - A function that already contains ${fn}() calls IS correctly handled \u2014 do not ask for more wrapping
362
+ - If the file already imports and uses ${fn} in multiple places, assume it is correctly integrated
363
+
364
+ Respond with JSON only (no markdown fences, no explanation):
365
+ - If everything is correct: {"passed":true}
366
+ - If there are issues: {"passed":false,"issues":["description of each issue"],"diff":"unified diff that fixes ALL issues"}
367
+
368
+ The diff MUST:
369
+ - Use standard unified diff format (--- a/file, +++ b/file, @@ hunks)
370
+ - Only add/modify transformer-related code OR fix broken code caused by the patch
371
+ - Restore any accidentally removed functions, variables, or exports
372
+ - Not remove or change any existing logic beyond what's needed for the fix`;
373
+ const result = await chatCompletion(prompt, { model, maxTokens: 4e3 });
374
+ if (isOpenAiError(result)) {
375
+ return { passed: false, issues: [result.error], tokensUsed: 0 };
376
+ }
377
+ const raw = result.content.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
378
+ try {
379
+ const parsed = JSON.parse(raw);
380
+ if (parsed.passed === true) {
381
+ return { passed: true, issues: [], tokensUsed: result.tokensUsed };
382
+ }
383
+ const issues = Array.isArray(parsed.issues) ? parsed.issues : [];
384
+ const diff = typeof parsed.diff === "string" ? parsed.diff : void 0;
385
+ return { passed: false, issues, diff, tokensUsed: result.tokensUsed };
386
+ } catch {
387
+ return {
388
+ passed: false,
389
+ issues: ["AI returned non-JSON response"],
390
+ tokensUsed: result.tokensUsed
391
+ };
392
+ }
393
+ }
394
+ function applyAiDiff(root, filePath, originalContent, diff) {
395
+ const validation = validateDiff(diff, [filePath]);
396
+ if (!validation.valid) {
397
+ consola.warn(` AI diff validation failed: ${validation.errors.join("; ")}`);
398
+ return false;
399
+ }
400
+ const newContent = applyUnifiedDiff(originalContent, diff);
401
+ if (!newContent || newContent === originalContent) {
402
+ return false;
403
+ }
404
+ try {
405
+ const absPath = join2(root, filePath);
406
+ writeFileSync(absPath, newContent, "utf-8");
407
+ return true;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+ function applyUnifiedDiff(original, diff) {
413
+ const lines = original.split("\n");
414
+ const diffLines = diff.split("\n");
415
+ const result = [...lines];
416
+ let offset = 0;
417
+ for (let i = 0; i < diffLines.length; i++) {
418
+ const line = diffLines[i];
419
+ const hunkMatch = line.match(/^@@\s*-(\d+)(?:,(\d+))?\s*\+(\d+)(?:,(\d+))?\s*@@/);
420
+ if (!hunkMatch) continue;
421
+ const oldStart = parseInt(hunkMatch[1], 10) - 1;
422
+ let pos = oldStart + offset;
423
+ for (let j = i + 1; j < diffLines.length; j++) {
424
+ const hunkLine = diffLines[j];
425
+ if (hunkLine.startsWith("@@") || hunkLine.startsWith("diff ") || hunkLine.startsWith("--- ") || hunkLine.startsWith("+++ ")) {
426
+ break;
427
+ }
428
+ if (hunkLine.startsWith("-")) {
429
+ const expected = hunkLine.substring(1);
430
+ if (pos < result.length && result[pos].trim() === expected.trim()) {
431
+ result.splice(pos, 1);
432
+ offset--;
433
+ } else {
434
+ let found = false;
435
+ for (let k = Math.max(0, pos - 8); k < Math.min(result.length, pos + 9); k++) {
436
+ if (result[k].trim() === expected.trim()) {
437
+ result.splice(k, 1);
438
+ offset--;
439
+ pos = k;
440
+ found = true;
441
+ break;
442
+ }
443
+ }
444
+ if (!found) return null;
445
+ }
446
+ } else if (hunkLine.startsWith("+")) {
447
+ const newLine = hunkLine.substring(1);
448
+ result.splice(pos, 0, newLine);
449
+ pos++;
450
+ offset++;
451
+ } else if (hunkLine.startsWith(" ")) {
452
+ pos++;
453
+ }
454
+ }
455
+ }
456
+ return result.join("\n");
457
+ }
458
+
459
+ export {
460
+ aiReviewAll
461
+ };