@synchronized-studio/cmsassets-agent 0.1.2 → 0.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/dist/aiReview-2CEFN6ZS.js +9 -0
- package/dist/aiReview-6WOTHK5N.js +9 -0
- package/dist/chunk-E74TGIFQ.js +88 -0
- package/dist/chunk-OAWCNTAC.js +397 -0
- package/dist/chunk-OJVHNQQN.js +1035 -0
- package/dist/chunk-Q6VYUIS7.js +1035 -0
- package/dist/chunk-RALRI2YO.js +406 -0
- package/dist/chunk-WDZHZ32V.js +1433 -0
- package/dist/chunk-YZRA5AHC.js +1706 -0
- package/dist/cli.js +88 -42
- package/dist/index.d.ts +9 -9
- package/dist/index.js +4 -3
- package/dist/openaiClient-YGAFYB3X.js +11 -0
- package/package.json +2 -2
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/llm/openaiClient.ts
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
function isOpenAiError(res) {
|
|
5
|
+
return res.content === null;
|
|
6
|
+
}
|
|
7
|
+
var cachedOpenai = null;
|
|
8
|
+
async function checkAiVerifyReady(projectRoot) {
|
|
9
|
+
cachedOpenai = null;
|
|
10
|
+
try {
|
|
11
|
+
const openai = await import("./openai-E6ORPCAV.js");
|
|
12
|
+
cachedOpenai = openai;
|
|
13
|
+
} catch {
|
|
14
|
+
if (projectRoot) {
|
|
15
|
+
try {
|
|
16
|
+
const require2 = createRequire(import.meta.url);
|
|
17
|
+
const openai = require2(join(projectRoot, "node_modules/openai"));
|
|
18
|
+
cachedOpenai = openai?.default ?? openai;
|
|
19
|
+
} catch {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
reason: 'The "openai" package is required for AI verification.',
|
|
23
|
+
canInstallInProject: true
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
reason: 'AI verification needs the "openai" package. Run init with --ai-verify from your project so the agent can install it there.',
|
|
30
|
+
canInstallInProject: false
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: "AI verification requires OPENAI_API_KEY to be set in your environment."
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
|
42
|
+
async function chatCompletion(prompt, opts = {}) {
|
|
43
|
+
let openai = cachedOpenai;
|
|
44
|
+
if (!openai) {
|
|
45
|
+
try {
|
|
46
|
+
const mod = await import("./openai-E6ORPCAV.js");
|
|
47
|
+
openai = mod;
|
|
48
|
+
cachedOpenai = mod;
|
|
49
|
+
} catch {
|
|
50
|
+
return {
|
|
51
|
+
content: null,
|
|
52
|
+
tokensUsed: 0,
|
|
53
|
+
error: "openai package not installed. Install it with: npm install openai"
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
58
|
+
if (!apiKey) {
|
|
59
|
+
return {
|
|
60
|
+
content: null,
|
|
61
|
+
tokensUsed: 0,
|
|
62
|
+
error: "OPENAI_API_KEY environment variable not set"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const mod = openai;
|
|
67
|
+
const Client = mod?.default ?? openai;
|
|
68
|
+
const client = new Client({ apiKey });
|
|
69
|
+
const response = await client.chat.completions.create({
|
|
70
|
+
model: opts.model ?? "gpt-4o",
|
|
71
|
+
messages: [{ role: "user", content: prompt }],
|
|
72
|
+
temperature: opts.temperature ?? 0,
|
|
73
|
+
max_tokens: opts.maxTokens ?? 4e3
|
|
74
|
+
});
|
|
75
|
+
const content = response.choices[0]?.message?.content?.trim() ?? "";
|
|
76
|
+
const tokensUsed = (response.usage?.prompt_tokens ?? 0) + (response.usage?.completion_tokens ?? 0);
|
|
77
|
+
return { content, tokensUsed };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
return { content: null, tokensUsed: 0, error: `OpenAI API error: ${msg}` };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
isOpenAiError,
|
|
86
|
+
checkAiVerifyReady,
|
|
87
|
+
chatCompletion
|
|
88
|
+
};
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCmsOptions,
|
|
3
|
+
findInjectionPoints,
|
|
4
|
+
getTransformFunctionName,
|
|
5
|
+
validateDiff
|
|
6
|
+
} from "./chunk-Q6VYUIS7.js";
|
|
7
|
+
import {
|
|
8
|
+
chatCompletion,
|
|
9
|
+
isOpenAiError
|
|
10
|
+
} from "./chunk-E74TGIFQ.js";
|
|
11
|
+
|
|
12
|
+
// src/verifier/aiReview.ts
|
|
13
|
+
import { readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
14
|
+
import { join as join2 } from "path";
|
|
15
|
+
import consola from "consola";
|
|
16
|
+
import pc from "picocolors";
|
|
17
|
+
|
|
18
|
+
// src/verifier/rescan.ts
|
|
19
|
+
import { readFileSync } from "fs";
|
|
20
|
+
import { join, relative } from "path";
|
|
21
|
+
function rescanFile(root, filePath, framework, cms) {
|
|
22
|
+
const absPath = join(root, filePath);
|
|
23
|
+
let content;
|
|
24
|
+
try {
|
|
25
|
+
content = readFileSync(absPath, "utf-8");
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const hasTransformer = content.includes("@synchronized-studio/response-transformer");
|
|
30
|
+
if (!hasTransformer) {
|
|
31
|
+
return findInjectionPoints(root, framework, cms).filter((c) => c.filePath === relative(root, absPath) || c.filePath === filePath);
|
|
32
|
+
}
|
|
33
|
+
return findUnwrappedCmsCalls(content, filePath, cms);
|
|
34
|
+
}
|
|
35
|
+
var CMS_CALL_PATTERNS = [
|
|
36
|
+
/client\.\w+\s*\(/g,
|
|
37
|
+
/\$prismic\.\w+\s*\(/g,
|
|
38
|
+
/\$contentful\.\w+\s*\(/g,
|
|
39
|
+
/\$sanity\.\w+\s*\(/g,
|
|
40
|
+
/usePrismic\(\)/g,
|
|
41
|
+
/createClient\(\)/g,
|
|
42
|
+
/\$fetch\s*\(\s*['"`]\/api\//g
|
|
43
|
+
];
|
|
44
|
+
var TRANSFORM_WRAPPERS = [
|
|
45
|
+
"transformPrismicAssetUrls",
|
|
46
|
+
"transformContentfulAssetUrls",
|
|
47
|
+
"transformSanityAssetUrls",
|
|
48
|
+
"transformShopifyAssetUrls",
|
|
49
|
+
"transformCloudinaryAssetUrls",
|
|
50
|
+
"transformImgixAssetUrls",
|
|
51
|
+
"transformGenericAssetUrls",
|
|
52
|
+
"transformCmsAssetUrls"
|
|
53
|
+
];
|
|
54
|
+
function findEnclosingFunctionRange(lines, lineIdx) {
|
|
55
|
+
let start = lineIdx;
|
|
56
|
+
let braceDepth = 0;
|
|
57
|
+
for (let i = lineIdx; i >= 0; i--) {
|
|
58
|
+
for (const c of lines[i]) {
|
|
59
|
+
if (c === "}") braceDepth++;
|
|
60
|
+
else if (c === "{") braceDepth--;
|
|
61
|
+
}
|
|
62
|
+
if (/(?:async\s+)?(?:function\b|\w+\s*\(|=>\s*\{)/.test(lines[i]) && braceDepth <= 0) {
|
|
63
|
+
start = i;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
braceDepth = 0;
|
|
68
|
+
let end = lineIdx;
|
|
69
|
+
for (let i = start; i < lines.length; i++) {
|
|
70
|
+
for (const c of lines[i]) {
|
|
71
|
+
if (c === "{") braceDepth++;
|
|
72
|
+
else if (c === "}") braceDepth--;
|
|
73
|
+
}
|
|
74
|
+
if (braceDepth === 0 && i > start) {
|
|
75
|
+
end = i;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { start, end };
|
|
80
|
+
}
|
|
81
|
+
function findUnwrappedCmsCalls(content, filePath, cms) {
|
|
82
|
+
const lines = content.split("\n");
|
|
83
|
+
const candidates = [];
|
|
84
|
+
for (let i = 0; i < lines.length; i++) {
|
|
85
|
+
const line = lines[i];
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (trimmed.startsWith("import ") || trimmed.startsWith("//") || trimmed.startsWith("*")) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const hasCmsCall = CMS_CALL_PATTERNS.some((p) => {
|
|
91
|
+
p.lastIndex = 0;
|
|
92
|
+
return p.test(trimmed);
|
|
93
|
+
});
|
|
94
|
+
if (!hasCmsCall) continue;
|
|
95
|
+
const isWrappedInline = TRANSFORM_WRAPPERS.some((fn) => trimmed.includes(fn));
|
|
96
|
+
if (isWrappedInline) continue;
|
|
97
|
+
const fnRange = findEnclosingFunctionRange(lines, i);
|
|
98
|
+
const fnBody = lines.slice(fnRange.start, fnRange.end + 1).join("\n");
|
|
99
|
+
const fnHasTransform = TRANSFORM_WRAPPERS.some((fn) => fnBody.includes(fn));
|
|
100
|
+
if (fnHasTransform) continue;
|
|
101
|
+
const simpleAssign = trimmed.match(/(?:const|let)\s+(\w+)\s*=/);
|
|
102
|
+
const destructAssign = trimmed.match(/(?:const|let)\s+\{([^}]+)\}\s*=/);
|
|
103
|
+
const varNames = [];
|
|
104
|
+
if (simpleAssign) varNames.push(simpleAssign[1]);
|
|
105
|
+
if (destructAssign) {
|
|
106
|
+
varNames.push(...destructAssign[1].split(",").map((s) => s.trim().split(":")[0].trim()).filter(Boolean));
|
|
107
|
+
}
|
|
108
|
+
if (varNames.length > 0) {
|
|
109
|
+
const ahead = lines.slice(i + 1, Math.min(lines.length, i + 40)).join("\n");
|
|
110
|
+
const isAnyVarWrapped = varNames.some(
|
|
111
|
+
(varName) => TRANSFORM_WRAPPERS.some(
|
|
112
|
+
(fn) => ahead.includes(`${fn}(${varName}`) || ahead.includes(`${fn}( ${varName}`) || new RegExp(`${fn}\\([\\s\\S]{0,300}\\b${varName}\\b`).test(ahead)
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
if (isAnyVarWrapped) continue;
|
|
116
|
+
}
|
|
117
|
+
const contextSlice = lines.slice(Math.max(0, i - 10), Math.min(lines.length, i + 15)).join("\n");
|
|
118
|
+
if (/transform\s*:/.test(contextSlice)) continue;
|
|
119
|
+
candidates.push({
|
|
120
|
+
filePath,
|
|
121
|
+
line: i + 1,
|
|
122
|
+
type: "return",
|
|
123
|
+
score: 70,
|
|
124
|
+
confidence: "medium",
|
|
125
|
+
targetCode: trimmed,
|
|
126
|
+
context: lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join("\n"),
|
|
127
|
+
reasons: ["CMS SDK call without transform wrapper (post-patch rescan)"]
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return candidates;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/verifier/aiReview.ts
|
|
134
|
+
async function aiReviewAll(root, patchedFiles, plan, opts = {}) {
|
|
135
|
+
const startTime = Date.now();
|
|
136
|
+
const maxIterations = opts.maxIterations ?? 3;
|
|
137
|
+
const model = opts.model ?? "gpt-4o";
|
|
138
|
+
const results = [];
|
|
139
|
+
let totalTokens = 0;
|
|
140
|
+
const uniqueFiles = [...new Set(patchedFiles)];
|
|
141
|
+
for (const filePath of uniqueFiles) {
|
|
142
|
+
consola.info(`${pc.cyan("AI review:")} ${filePath}`);
|
|
143
|
+
const result = await reviewFileLoop(root, filePath, plan, {
|
|
144
|
+
maxIterations,
|
|
145
|
+
model
|
|
146
|
+
});
|
|
147
|
+
totalTokens += result.tokensUsed;
|
|
148
|
+
results.push(result.fileResult);
|
|
149
|
+
const icon = result.fileResult.passed ? pc.green("\u2713") : pc.red("\u2717");
|
|
150
|
+
const detail = result.fileResult.fixApplied ? pc.dim("(auto-fixed)") : "";
|
|
151
|
+
consola.info(` ${icon} ${filePath} ${detail}`);
|
|
152
|
+
if (!result.fileResult.passed) {
|
|
153
|
+
for (const issue of result.fileResult.issues) {
|
|
154
|
+
consola.info(` ${pc.yellow("\u26A0")} ${issue}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const filesPassed = results.filter((r) => r.passed).length;
|
|
159
|
+
const filesFixed = results.filter((r) => r.fixApplied).length;
|
|
160
|
+
const filesFailed = results.filter((r) => !r.passed).length;
|
|
161
|
+
return {
|
|
162
|
+
schemaVersion: "1.0",
|
|
163
|
+
filesReviewed: results.length,
|
|
164
|
+
filesPassed,
|
|
165
|
+
filesFixed,
|
|
166
|
+
filesFailed,
|
|
167
|
+
results,
|
|
168
|
+
totalDurationMs: Date.now() - startTime,
|
|
169
|
+
tokensUsed: totalTokens
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function reviewFileLoop(root, filePath, plan, opts) {
|
|
173
|
+
let tokensUsed = 0;
|
|
174
|
+
let fixAttempted = false;
|
|
175
|
+
let fixApplied = false;
|
|
176
|
+
for (let iter = 0; iter < opts.maxIterations; iter++) {
|
|
177
|
+
const remaining = rescanFile(
|
|
178
|
+
root,
|
|
179
|
+
filePath,
|
|
180
|
+
plan.scan.framework.name,
|
|
181
|
+
plan.scan.cms
|
|
182
|
+
);
|
|
183
|
+
const rescanIssues = remaining.map(
|
|
184
|
+
(c) => `Line ${c.line}: ${c.targetCode.substring(0, 80)}`
|
|
185
|
+
);
|
|
186
|
+
const absPath = join2(root, filePath);
|
|
187
|
+
let content;
|
|
188
|
+
try {
|
|
189
|
+
content = readFileSync2(absPath, "utf-8");
|
|
190
|
+
} catch {
|
|
191
|
+
return {
|
|
192
|
+
fileResult: {
|
|
193
|
+
filePath,
|
|
194
|
+
passed: false,
|
|
195
|
+
issues: ["Cannot read file"],
|
|
196
|
+
fixAttempted,
|
|
197
|
+
fixApplied,
|
|
198
|
+
iterations: iter + 1
|
|
199
|
+
},
|
|
200
|
+
tokensUsed
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const reviewResult = await aiSemanticReview(content, filePath, plan, opts.model, rescanIssues);
|
|
204
|
+
tokensUsed += reviewResult.tokensUsed;
|
|
205
|
+
if (reviewResult.passed) {
|
|
206
|
+
return {
|
|
207
|
+
fileResult: {
|
|
208
|
+
filePath,
|
|
209
|
+
passed: true,
|
|
210
|
+
issues: [],
|
|
211
|
+
fixAttempted,
|
|
212
|
+
fixApplied,
|
|
213
|
+
iterations: iter + 1
|
|
214
|
+
},
|
|
215
|
+
tokensUsed
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (!reviewResult.diff) {
|
|
219
|
+
return {
|
|
220
|
+
fileResult: {
|
|
221
|
+
filePath,
|
|
222
|
+
passed: false,
|
|
223
|
+
issues: reviewResult.issues,
|
|
224
|
+
fixAttempted,
|
|
225
|
+
fixApplied,
|
|
226
|
+
iterations: iter + 1
|
|
227
|
+
},
|
|
228
|
+
tokensUsed
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
fixAttempted = true;
|
|
232
|
+
const applied = applyAiDiff(root, filePath, content, reviewResult.diff);
|
|
233
|
+
if (applied) {
|
|
234
|
+
fixApplied = true;
|
|
235
|
+
consola.info(` ${pc.blue("\u21BB")} Applied AI fix (iteration ${iter + 1})`);
|
|
236
|
+
} else {
|
|
237
|
+
return {
|
|
238
|
+
fileResult: {
|
|
239
|
+
filePath,
|
|
240
|
+
passed: false,
|
|
241
|
+
issues: [...reviewResult.issues, "AI fix could not be applied"],
|
|
242
|
+
fixAttempted,
|
|
243
|
+
fixApplied,
|
|
244
|
+
iterations: iter + 1
|
|
245
|
+
},
|
|
246
|
+
tokensUsed
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const finalRemaining = rescanFile(root, filePath, plan.scan.framework.name, plan.scan.cms);
|
|
251
|
+
const passed = finalRemaining.length === 0;
|
|
252
|
+
return {
|
|
253
|
+
fileResult: {
|
|
254
|
+
filePath,
|
|
255
|
+
passed,
|
|
256
|
+
issues: passed ? [] : finalRemaining.map((c) => `Line ${c.line}: unwrapped CMS call \u2014 ${c.targetCode.substring(0, 80)}`),
|
|
257
|
+
fixAttempted,
|
|
258
|
+
fixApplied,
|
|
259
|
+
iterations: opts.maxIterations
|
|
260
|
+
},
|
|
261
|
+
tokensUsed
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async function aiSemanticReview(content, filePath, plan, model, rescanIssues) {
|
|
265
|
+
const fn = getTransformFunctionName(plan.scan.cms.type);
|
|
266
|
+
const opts = buildCmsOptions(plan.scan.cms.type, plan.scan.cms.params);
|
|
267
|
+
const rescanSection = rescanIssues.length > 0 ? `
|
|
268
|
+
Rule-based rescan found these potentially unwrapped CMS calls:
|
|
269
|
+
${rescanIssues.map((i) => `- ${i}`).join("\n")}
|
|
270
|
+
` : "";
|
|
271
|
+
const prompt = `You are verifying that CMS asset URL transformations were correctly applied to a source file.
|
|
272
|
+
|
|
273
|
+
CMS: ${plan.scan.cms.type}
|
|
274
|
+
Transform function: ${fn}
|
|
275
|
+
Expected options: ${opts}
|
|
276
|
+
Framework: ${plan.scan.framework.name}
|
|
277
|
+
${rescanSection}
|
|
278
|
+
File (${filePath}):
|
|
279
|
+
\`\`\`
|
|
280
|
+
${content}
|
|
281
|
+
\`\`\`
|
|
282
|
+
|
|
283
|
+
Check ONLY the following \u2014 do NOT be overly aggressive:
|
|
284
|
+
1. Is the import \`import { ${fn} } from '@synchronized-studio/response-transformer'\` present?
|
|
285
|
+
2. For functions that RETURN CMS data objects/arrays, is the return wrapped with ${fn}()?
|
|
286
|
+
- Wrapping at the return level is CORRECT and SUFFICIENT.
|
|
287
|
+
- \`return ${fn}({ model: document }, ${opts})\` is correct.
|
|
288
|
+
- \`return ${fn}(data, ${opts})\` is correct.
|
|
289
|
+
- Do NOT flag intermediate CMS SDK calls ($prismic, $contentful, client.get) as unwrapped
|
|
290
|
+
when the function's return is already wrapped.
|
|
291
|
+
3. Are the options (${opts}) correct in every ${fn}() call?
|
|
292
|
+
|
|
293
|
+
IMPORTANT \u2014 do NOT flag any of these as issues:
|
|
294
|
+
- Functions that do NOT return a value (e.g. they only commit to Vuex store) \u2014 these are FINE as-is
|
|
295
|
+
- Functions that return simple strings, URLs, booleans, or "#" \u2014 these are NOT CMS data
|
|
296
|
+
- Error returns like \`return err\`, \`return []\`, \`return undefined\` \u2014 skip these
|
|
297
|
+
- Functions that return \`dispatch(...)\` or delegate to another action \u2014 the target action handles the transform
|
|
298
|
+
- A function that already contains ${fn}() calls IS correctly handled \u2014 do not ask for more wrapping
|
|
299
|
+
- If the file already imports and uses ${fn} in multiple places, assume it is correctly integrated
|
|
300
|
+
|
|
301
|
+
Respond with JSON only (no markdown fences, no explanation):
|
|
302
|
+
- If everything is correct: {"passed":true}
|
|
303
|
+
- If there are issues: {"passed":false,"issues":["description of each issue"],"diff":"unified diff that fixes ALL issues"}
|
|
304
|
+
|
|
305
|
+
The diff MUST:
|
|
306
|
+
- Use standard unified diff format (--- a/file, +++ b/file, @@ hunks)
|
|
307
|
+
- Only add/modify transformer-related code
|
|
308
|
+
- Not remove or change any existing logic`;
|
|
309
|
+
const result = await chatCompletion(prompt, { model, maxTokens: 4e3 });
|
|
310
|
+
if (isOpenAiError(result)) {
|
|
311
|
+
return { passed: false, issues: [result.error], tokensUsed: 0 };
|
|
312
|
+
}
|
|
313
|
+
const raw = result.content.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(raw);
|
|
316
|
+
if (parsed.passed === true) {
|
|
317
|
+
return { passed: true, issues: [], tokensUsed: result.tokensUsed };
|
|
318
|
+
}
|
|
319
|
+
const issues = Array.isArray(parsed.issues) ? parsed.issues : [];
|
|
320
|
+
const diff = typeof parsed.diff === "string" ? parsed.diff : void 0;
|
|
321
|
+
return { passed: false, issues, diff, tokensUsed: result.tokensUsed };
|
|
322
|
+
} catch {
|
|
323
|
+
return {
|
|
324
|
+
passed: false,
|
|
325
|
+
issues: ["AI returned non-JSON response"],
|
|
326
|
+
tokensUsed: result.tokensUsed
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function applyAiDiff(root, filePath, originalContent, diff) {
|
|
331
|
+
const validation = validateDiff(diff, [filePath]);
|
|
332
|
+
if (!validation.valid) {
|
|
333
|
+
consola.warn(` AI diff validation failed: ${validation.errors.join("; ")}`);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const newContent = applyUnifiedDiff(originalContent, diff);
|
|
337
|
+
if (!newContent || newContent === originalContent) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const absPath = join2(root, filePath);
|
|
342
|
+
writeFileSync(absPath, newContent, "utf-8");
|
|
343
|
+
return true;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function applyUnifiedDiff(original, diff) {
|
|
349
|
+
const lines = original.split("\n");
|
|
350
|
+
const diffLines = diff.split("\n");
|
|
351
|
+
const result = [...lines];
|
|
352
|
+
let offset = 0;
|
|
353
|
+
for (let i = 0; i < diffLines.length; i++) {
|
|
354
|
+
const line = diffLines[i];
|
|
355
|
+
const hunkMatch = line.match(/^@@\s*-(\d+)(?:,(\d+))?\s*\+(\d+)(?:,(\d+))?\s*@@/);
|
|
356
|
+
if (!hunkMatch) continue;
|
|
357
|
+
const oldStart = parseInt(hunkMatch[1], 10) - 1;
|
|
358
|
+
let pos = oldStart + offset;
|
|
359
|
+
for (let j = i + 1; j < diffLines.length; j++) {
|
|
360
|
+
const hunkLine = diffLines[j];
|
|
361
|
+
if (hunkLine.startsWith("@@") || hunkLine.startsWith("diff ") || hunkLine.startsWith("--- ") || hunkLine.startsWith("+++ ")) {
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
if (hunkLine.startsWith("-")) {
|
|
365
|
+
const expected = hunkLine.substring(1);
|
|
366
|
+
if (pos < result.length && result[pos].trim() === expected.trim()) {
|
|
367
|
+
result.splice(pos, 1);
|
|
368
|
+
offset--;
|
|
369
|
+
} else {
|
|
370
|
+
let found = false;
|
|
371
|
+
for (let k = Math.max(0, pos - 8); k < Math.min(result.length, pos + 9); k++) {
|
|
372
|
+
if (result[k].trim() === expected.trim()) {
|
|
373
|
+
result.splice(k, 1);
|
|
374
|
+
offset--;
|
|
375
|
+
pos = k;
|
|
376
|
+
found = true;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!found) return null;
|
|
381
|
+
}
|
|
382
|
+
} else if (hunkLine.startsWith("+")) {
|
|
383
|
+
const newLine = hunkLine.substring(1);
|
|
384
|
+
result.splice(pos, 0, newLine);
|
|
385
|
+
pos++;
|
|
386
|
+
offset++;
|
|
387
|
+
} else if (hunkLine.startsWith(" ")) {
|
|
388
|
+
pos++;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return result.join("\n");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export {
|
|
396
|
+
aiReviewAll
|
|
397
|
+
};
|