@synchronized-studio/cmsassets-agent 0.5.1 → 0.6.1

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,492 @@
1
+ import {
2
+ buildCmsOptions,
3
+ findInjectionPoints,
4
+ getTransformFunctionName
5
+ } from "./chunk-W7TNFULQ.js";
6
+ import {
7
+ chatCompletion,
8
+ isOpenAiError
9
+ } from "./chunk-3D6MPYQN.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).candidates.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 stripLineComment(line) {
132
+ if (/^\s*(import\s|.*\brequire\s*\()/.test(line)) return line;
133
+ return line.replace(/\/\/.*$/, "");
134
+ }
135
+ function findUnwrappedCmsCalls(content, filePath, cms) {
136
+ const strippedContent = content.replace(
137
+ /\/\*[\s\S]*?\*\//g,
138
+ (match) => match.split("\n").map(() => "").join("\n")
139
+ );
140
+ const lines = strippedContent.split("\n");
141
+ const originalLines = content.split("\n");
142
+ const candidates = [];
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const line = stripLineComment(lines[i]);
145
+ const trimmed = line.trim();
146
+ if (!trimmed || trimmed.startsWith("import ") || trimmed.startsWith("*")) {
147
+ continue;
148
+ }
149
+ const hasCmsCall = CMS_CALL_PATTERNS.some((p) => {
150
+ p.lastIndex = 0;
151
+ return p.test(trimmed);
152
+ });
153
+ if (!hasCmsCall) continue;
154
+ const isWrappedInline = TRANSFORM_WRAPPERS.some((fn) => trimmed.includes(fn));
155
+ if (isWrappedInline) continue;
156
+ const fnRange = findEnclosingFunctionRange(lines, i);
157
+ const fnBody = lines.slice(fnRange.start, fnRange.end + 1).join("\n");
158
+ const fnHasTransform = TRANSFORM_WRAPPERS.some((fn) => fnBody.includes(fn));
159
+ if (fnHasTransform) continue;
160
+ const simpleAssign = trimmed.match(/(?:const|let)\s+(\w+)\s*=/);
161
+ const destructAssign = trimmed.match(/(?:const|let)\s+\{([^}]+)\}\s*=/);
162
+ const varNames = [];
163
+ if (simpleAssign) varNames.push(simpleAssign[1]);
164
+ if (destructAssign) {
165
+ varNames.push(...destructAssign[1].split(",").map((s) => s.trim().split(":")[0].trim()).filter(Boolean));
166
+ }
167
+ if (varNames.length > 0) {
168
+ const ahead = lines.slice(i + 1, Math.min(lines.length, i + 40)).join("\n");
169
+ const isAnyVarWrapped = varNames.some(
170
+ (varName) => TRANSFORM_WRAPPERS.some(
171
+ (fn) => ahead.includes(`${fn}(${varName}`) || ahead.includes(`${fn}( ${varName}`) || new RegExp(`${fn}\\([\\s\\S]{0,300}\\b${varName}\\b`).test(ahead)
172
+ )
173
+ );
174
+ if (isAnyVarWrapped) continue;
175
+ }
176
+ const contextSlice = lines.slice(Math.max(0, i - 10), Math.min(lines.length, i + 15)).join("\n");
177
+ if (/transform\s*:/.test(contextSlice)) continue;
178
+ candidates.push({
179
+ filePath,
180
+ line: i + 1,
181
+ type: "return",
182
+ score: 70,
183
+ confidence: "medium",
184
+ targetCode: originalLines[i]?.trim() ?? trimmed,
185
+ context: originalLines.slice(Math.max(0, i - 2), Math.min(originalLines.length, i + 3)).join("\n"),
186
+ reasons: ["CMS SDK call without transform wrapper (post-patch rescan)"]
187
+ });
188
+ }
189
+ return candidates;
190
+ }
191
+
192
+ // src/verifier/aiReview.ts
193
+ async function aiReviewAll(root, patchedFiles, plan, opts = {}) {
194
+ const startTime = Date.now();
195
+ const maxIterations = opts.maxIterations ?? 3;
196
+ const model = opts.model ?? "gpt-4o";
197
+ const results = [];
198
+ let totalTokens = 0;
199
+ const uniqueFiles = [...new Set(patchedFiles)];
200
+ for (const filePath of uniqueFiles) {
201
+ consola.info(`${pc.cyan("AI review:")} ${filePath}`);
202
+ const result = await reviewFileLoop(root, filePath, plan, {
203
+ maxIterations,
204
+ model
205
+ });
206
+ totalTokens += result.tokensUsed;
207
+ results.push(result.fileResult);
208
+ const icon = result.fileResult.passed ? pc.green("\u2713") : pc.red("\u2717");
209
+ const detail = result.fileResult.fixApplied ? pc.dim("(auto-fixed)") : "";
210
+ consola.info(` ${icon} ${filePath} ${detail}`);
211
+ if (!result.fileResult.passed) {
212
+ for (const issue of result.fileResult.issues) {
213
+ consola.info(` ${pc.yellow("\u26A0")} ${issue}`);
214
+ }
215
+ }
216
+ }
217
+ const filesPassed = results.filter((r) => r.passed).length;
218
+ const filesFixed = results.filter((r) => r.fixApplied).length;
219
+ const filesFailed = results.filter((r) => !r.passed).length;
220
+ return {
221
+ schemaVersion: "1.0",
222
+ filesReviewed: results.length,
223
+ filesPassed,
224
+ filesFixed,
225
+ filesFailed,
226
+ results,
227
+ totalDurationMs: Date.now() - startTime,
228
+ tokensUsed: totalTokens
229
+ };
230
+ }
231
+ async function reviewFileLoop(root, filePath, plan, opts) {
232
+ let tokensUsed = 0;
233
+ let fixAttempted = false;
234
+ let fixApplied = false;
235
+ for (let iter = 0; iter < opts.maxIterations; iter++) {
236
+ const remaining = rescanFile(
237
+ root,
238
+ filePath,
239
+ plan.scan.framework.name,
240
+ plan.scan.cms
241
+ );
242
+ const rescanIssues = remaining.map(
243
+ (c) => `Line ${c.line}: ${c.targetCode.substring(0, 80)}`
244
+ );
245
+ const absPath = join2(root, filePath);
246
+ let content;
247
+ try {
248
+ content = readFileSync2(absPath, "utf-8");
249
+ } catch {
250
+ return {
251
+ fileResult: {
252
+ filePath,
253
+ passed: false,
254
+ issues: ["Cannot read file"],
255
+ fixAttempted,
256
+ fixApplied,
257
+ iterations: iter + 1
258
+ },
259
+ tokensUsed
260
+ };
261
+ }
262
+ const reviewResult = await aiSemanticReview(content, filePath, plan, opts.model, rescanIssues);
263
+ tokensUsed += reviewResult.tokensUsed;
264
+ if (reviewResult.passed) {
265
+ return {
266
+ fileResult: {
267
+ filePath,
268
+ passed: true,
269
+ issues: [],
270
+ fixAttempted,
271
+ fixApplied,
272
+ iterations: iter + 1
273
+ },
274
+ tokensUsed
275
+ };
276
+ }
277
+ if (!reviewResult.diff) {
278
+ return {
279
+ fileResult: {
280
+ filePath,
281
+ passed: false,
282
+ issues: reviewResult.issues,
283
+ fixAttempted,
284
+ fixApplied,
285
+ iterations: iter + 1
286
+ },
287
+ tokensUsed
288
+ };
289
+ }
290
+ fixAttempted = true;
291
+ const applied = applyAiDiff(root, filePath, content, reviewResult.diff);
292
+ if (applied) {
293
+ fixApplied = true;
294
+ consola.info(` ${pc.blue("\u21BB")} Applied AI fix (iteration ${iter + 1})`);
295
+ } else {
296
+ return {
297
+ fileResult: {
298
+ filePath,
299
+ passed: false,
300
+ issues: [...reviewResult.issues, "AI fix could not be applied"],
301
+ fixAttempted,
302
+ fixApplied,
303
+ iterations: iter + 1
304
+ },
305
+ tokensUsed
306
+ };
307
+ }
308
+ }
309
+ const finalRemaining = rescanFile(root, filePath, plan.scan.framework.name, plan.scan.cms);
310
+ const passed = finalRemaining.length === 0;
311
+ return {
312
+ fileResult: {
313
+ filePath,
314
+ passed,
315
+ issues: passed ? [] : finalRemaining.map((c) => `Line ${c.line}: unwrapped CMS call \u2014 ${c.targetCode.substring(0, 80)}`),
316
+ fixAttempted,
317
+ fixApplied,
318
+ iterations: opts.maxIterations
319
+ },
320
+ tokensUsed
321
+ };
322
+ }
323
+ async function aiSemanticReview(content, filePath, plan, model, rescanIssues) {
324
+ const fn = getTransformFunctionName(plan.scan.cms.type);
325
+ const opts = buildCmsOptions(plan.scan.cms.type, plan.scan.cms.params);
326
+ const rescanSection = rescanIssues.length > 0 ? `
327
+ Rule-based rescan found these potentially unwrapped CMS calls:
328
+ ${rescanIssues.map((i) => `- ${i}`).join("\n")}
329
+ ` : "";
330
+ 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.
331
+
332
+ CMS: ${plan.scan.cms.type}
333
+ Transform function: ${fn}
334
+ Expected options: ${opts}
335
+ Framework: ${plan.scan.framework.name}
336
+ ${rescanSection}
337
+ File (${filePath}):
338
+ \`\`\`
339
+ ${content}
340
+ \`\`\`
341
+
342
+ CHECK THE FOLLOWING:
343
+
344
+ A. Transform correctness:
345
+ 1. Is the import \`import { ${fn} } from '@synchronized-studio/response-transformer'\` present?
346
+ 2. For functions that RETURN CMS data objects/arrays, is the return wrapped with ${fn}()?
347
+ - Wrapping at the return level is CORRECT and SUFFICIENT.
348
+ - \`return ${fn}({ model: document }, ${opts})\` is correct.
349
+ - \`return ${fn}(data, ${opts})\` is correct.
350
+ - Do NOT flag intermediate CMS SDK calls ($prismic, $contentful, client.get) as unwrapped
351
+ when the function's return is already wrapped.
352
+ 3. Are the options (${opts}) correct in every ${fn}() call?
353
+
354
+ B. Code integrity (CRITICAL \u2014 the patch must not break existing code):
355
+ 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)
356
+ 5. Are there any obvious undefined references \u2014 calls to functions or variables that don't exist in the file?
357
+ 6. Does the code structure look intact \u2014 no truncated functions, missing closing brackets, orphaned statements?
358
+ 7. Are all exports still present and correct?
359
+ 8. CRITICAL: If a return value is an object containing FUNCTION references (like \`{ getPage, generateMetadata }\`),
360
+ wrapping it with ${fn}() will BREAK it because the transform uses JSON.stringify which destroys functions.
361
+ The transform should ONLY wrap CMS DATA returns (objects/arrays with data properties), NOT factory/API returns
362
+ that provide callable functions. If you see this pattern, report it as an issue and provide a diff that REMOVES
363
+ the incorrect wrapping.
364
+ 9. CRITICAL: Check for UNREACHABLE CODE \u2014 any statement placed after a \`return\` statement in the same block is dead code.
365
+ If ${fn}() was added as a return BEFORE existing side effects (like commit(), setState(), dispatch(), store mutations),
366
+ those side effects become unreachable and the function is broken. Report this as an issue and provide a diff that fixes it.
367
+ The correct pattern for functions that commit data is: transform the data first, then pass it to the commit.
368
+ 10. \`return dispatch(...)\` is delegation \u2014 do NOT wrap it with ${fn}(). The target action handles the transform.
369
+ 11. CRITICAL: ${fn}() must NEVER appear inside loop/iteration callbacks (forEach, _each, map, reduce, for...of).
370
+ The transform uses JSON.stringify on the ENTIRE data structure \u2014 calling it per-iteration is wasteful and often
371
+ operates on incomplete data. If you see ${fn}() inside a loop callback, report it as an issue and provide a diff
372
+ that moves the transform AFTER the loop, wrapping the final result.
373
+ 12. For Vuex/Pinia actions that both commit AND return the same data: the transform should be applied ONCE,
374
+ stored in a variable, and used for both the commit and the return. Double-transforming is a bug.
375
+ 13. NUXT 3 CLIENT-SIDE: If the framework is Nuxt 3 and the file is NOT in \`server/\`, verify that:
376
+ a) Transform options include \`cmsAssetsUrl\` referencing a variable that holds \`useRuntimeConfig()\`. Without this,
377
+ transforms silently fail on client-side navigation because \`process.env\` is not available on the client.
378
+ b) \`useRuntimeConfig()\` is NEVER called directly inside a callback (transform, useFetch handler, async callback).
379
+ It MUST be captured in a variable in the outer setup/composable scope (e.g. \`const config = useRuntimeConfig()\`)
380
+ and referenced by that variable. Calling it inside a callback loses the Nuxt composable context and throws.
381
+ If \`cmsAssetsUrl\` is missing or \`useRuntimeConfig()\` is called inside a callback, report as an issue with a fix diff.
382
+
383
+ IMPORTANT \u2014 do NOT flag any of these as transform issues:
384
+ - Functions that return simple strings, URLs, booleans, or "#" \u2014 these are NOT CMS data
385
+ - Error returns like \`return err\`, \`return []\`, \`return undefined\` \u2014 skip these
386
+ - Functions that return \`dispatch(...)\` or delegate to another action \u2014 the target action handles the transform
387
+ - A function that already contains ${fn}() calls IS correctly handled \u2014 do not ask for more wrapping
388
+ - If the file already imports and uses ${fn} in multiple places, assume it is correctly integrated
389
+
390
+ CRITICAL \u2014 report as issues and provide removal diffs for these INCORRECT wrappings:
391
+ - Returns that produce navigation/routing objects like \`{ query: ... }\`, \`{ path: ... }\`, \`{ redirect: ... }\` \u2014 these are Vue Router or framework navigation objects, NOT CMS data. Wrapping them with ${fn}() is wrong
392
+ - Returns in utility/filter functions that build URL query parameters, pagination objects, or UI state \u2014 NOT CMS data
393
+ - If \`__runtimeConfig\` is referenced but never declared (no \`const __runtimeConfig = useRuntimeConfig()\` in the file), report this as an undefined variable issue
394
+
395
+ Respond with JSON only (no markdown fences, no explanation):
396
+ - If everything is correct: {"passed":true}
397
+ - If there are issues: {"passed":false,"issues":["description of each issue"],"diff":"unified diff that fixes ALL issues"}
398
+
399
+ The diff MUST:
400
+ - Use standard unified diff format (--- a/file, +++ b/file, @@ hunks)
401
+ - Only add/modify transformer-related code OR fix broken code caused by the patch
402
+ - Restore any accidentally removed functions, variables, or exports
403
+ - Not remove or change any existing logic beyond what's needed for the fix`;
404
+ const result = await chatCompletion(prompt, { model, maxTokens: 4e3 });
405
+ if (isOpenAiError(result)) {
406
+ return { passed: false, issues: [result.error], tokensUsed: 0 };
407
+ }
408
+ const raw = result.content.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
409
+ try {
410
+ const parsed = JSON.parse(raw);
411
+ if (parsed.passed === true) {
412
+ return { passed: true, issues: [], tokensUsed: result.tokensUsed };
413
+ }
414
+ const issues = Array.isArray(parsed.issues) ? parsed.issues : [];
415
+ const diff = typeof parsed.diff === "string" ? parsed.diff : void 0;
416
+ return { passed: false, issues, diff, tokensUsed: result.tokensUsed };
417
+ } catch {
418
+ return {
419
+ passed: false,
420
+ issues: ["AI returned non-JSON response"],
421
+ tokensUsed: result.tokensUsed
422
+ };
423
+ }
424
+ }
425
+ function applyAiDiff(root, filePath, originalContent, diff) {
426
+ const validation = validateDiff(diff, [filePath]);
427
+ if (!validation.valid) {
428
+ consola.warn(` AI diff validation failed: ${validation.errors.join("; ")}`);
429
+ return false;
430
+ }
431
+ const newContent = applyUnifiedDiff(originalContent, diff);
432
+ if (!newContent || newContent === originalContent) {
433
+ return false;
434
+ }
435
+ try {
436
+ const absPath = join2(root, filePath);
437
+ writeFileSync(absPath, newContent, "utf-8");
438
+ return true;
439
+ } catch {
440
+ return false;
441
+ }
442
+ }
443
+ function applyUnifiedDiff(original, diff) {
444
+ const lines = original.split("\n");
445
+ const diffLines = diff.split("\n");
446
+ const result = [...lines];
447
+ let offset = 0;
448
+ for (let i = 0; i < diffLines.length; i++) {
449
+ const line = diffLines[i];
450
+ const hunkMatch = line.match(/^@@\s*-(\d+)(?:,(\d+))?\s*\+(\d+)(?:,(\d+))?\s*@@/);
451
+ if (!hunkMatch) continue;
452
+ const oldStart = parseInt(hunkMatch[1], 10) - 1;
453
+ let pos = oldStart + offset;
454
+ for (let j = i + 1; j < diffLines.length; j++) {
455
+ const hunkLine = diffLines[j];
456
+ if (hunkLine.startsWith("@@") || hunkLine.startsWith("diff ") || hunkLine.startsWith("--- ") || hunkLine.startsWith("+++ ")) {
457
+ break;
458
+ }
459
+ if (hunkLine.startsWith("-")) {
460
+ const expected = hunkLine.substring(1);
461
+ if (pos < result.length && result[pos].trim() === expected.trim()) {
462
+ result.splice(pos, 1);
463
+ offset--;
464
+ } else {
465
+ let found = false;
466
+ for (let k = Math.max(0, pos - 8); k < Math.min(result.length, pos + 9); k++) {
467
+ if (result[k].trim() === expected.trim()) {
468
+ result.splice(k, 1);
469
+ offset--;
470
+ pos = k;
471
+ found = true;
472
+ break;
473
+ }
474
+ }
475
+ if (!found) return null;
476
+ }
477
+ } else if (hunkLine.startsWith("+")) {
478
+ const newLine = hunkLine.substring(1);
479
+ result.splice(pos, 0, newLine);
480
+ pos++;
481
+ offset++;
482
+ } else if (hunkLine.startsWith(" ")) {
483
+ pos++;
484
+ }
485
+ }
486
+ }
487
+ return result.join("\n");
488
+ }
489
+
490
+ export {
491
+ aiReviewAll
492
+ };