kiroo 0.8.0 โ 0.9.5
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 +386 -293
- package/bin/kiroo.js +412 -288
- package/package.json +2 -1
- package/src/analyze.js +568 -0
- package/src/bench.js +11 -4
- package/src/checker.js +26 -9
- package/src/config.js +109 -0
- package/src/deterministic.js +22 -0
- package/src/env.js +31 -3
- package/src/executor.js +18 -1
- package/src/export.js +560 -93
- package/src/formatter.js +18 -6
- package/src/init.js +80 -48
- package/src/lingo.js +55 -36
- package/src/proxy.js +140 -0
- package/src/replay.js +5 -4
- package/src/run.js +246 -0
- package/src/sanitizer.js +100 -0
- package/src/snapshot.js +76 -19
- package/src/stats.js +15 -5
- package/src/storage.js +223 -142
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kiroo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Git for API interactions. Record, replay, snapshot, and diff your APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"cli-table3": "^0.6.3",
|
|
43
43
|
"commander": "^12.0.0",
|
|
44
44
|
"dotenv": "^17.3.1",
|
|
45
|
+
"http-proxy": "^1.18.1",
|
|
45
46
|
"inquirer": "^9.2.15",
|
|
46
47
|
"js-yaml": "^4.1.0",
|
|
47
48
|
"lingo.dev": "^0.133.1",
|
package/src/analyze.js
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { loadKirooConfig } from './config.js';
|
|
4
|
+
import { stableJSONStringify } from './deterministic.js';
|
|
5
|
+
import { loadSnapshotData } from './storage.js';
|
|
6
|
+
import { translateText } from './lingo.js';
|
|
7
|
+
import { getEnvVar } from './env.js';
|
|
8
|
+
|
|
9
|
+
const SEVERITY_RANK = {
|
|
10
|
+
none: 0,
|
|
11
|
+
low: 1,
|
|
12
|
+
medium: 2,
|
|
13
|
+
high: 3,
|
|
14
|
+
critical: 4
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MODEL_PRIORITY = [
|
|
18
|
+
'qwen/qwen3-32b',
|
|
19
|
+
'moonshotai/kimi-k2-instruct-0905',
|
|
20
|
+
'openai/gpt-oss-20b'
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function getPath(urlStr) {
|
|
24
|
+
try {
|
|
25
|
+
const urlObj = new URL(urlStr);
|
|
26
|
+
return urlObj.pathname;
|
|
27
|
+
} catch {
|
|
28
|
+
return urlStr.startsWith('/') ? urlStr : `/${urlStr}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function endpointKey(method, path) {
|
|
33
|
+
return `${String(method || '').toUpperCase()} ${path}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeFieldName(name) {
|
|
37
|
+
return String(name || '').replace(/[_-]/g, '').toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isObject(val) {
|
|
41
|
+
return val !== null && typeof val === 'object' && !Array.isArray(val);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compareStatus(beforeStatus, afterStatus) {
|
|
45
|
+
if (beforeStatus === afterStatus) return null;
|
|
46
|
+
|
|
47
|
+
const before2xx = beforeStatus >= 200 && beforeStatus < 300;
|
|
48
|
+
const before4xx5xx = beforeStatus >= 400;
|
|
49
|
+
const after3xx = afterStatus >= 300 && afterStatus < 400;
|
|
50
|
+
const after4xx5xx = afterStatus >= 400;
|
|
51
|
+
|
|
52
|
+
let severity = 'medium';
|
|
53
|
+
let breaking = true;
|
|
54
|
+
|
|
55
|
+
if (before2xx && afterStatus === 304) {
|
|
56
|
+
severity = 'low';
|
|
57
|
+
breaking = false;
|
|
58
|
+
} else if (before2xx && after3xx) {
|
|
59
|
+
severity = 'low';
|
|
60
|
+
breaking = false;
|
|
61
|
+
} else if (before2xx && after4xx5xx) {
|
|
62
|
+
severity = 'high';
|
|
63
|
+
breaking = true;
|
|
64
|
+
} else if (before4xx5xx && afterStatus >= 200 && afterStatus < 300) {
|
|
65
|
+
severity = 'low';
|
|
66
|
+
breaking = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
type: 'status_changed',
|
|
71
|
+
path: 'response.status',
|
|
72
|
+
severity,
|
|
73
|
+
breaking,
|
|
74
|
+
message: `Status changed from ${beforeStatus} to ${afterStatus}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function compareBodies(beforeValue, afterValue, path = '') {
|
|
79
|
+
const issues = [];
|
|
80
|
+
|
|
81
|
+
if (beforeValue === null && afterValue === null) return issues;
|
|
82
|
+
if (beforeValue === null && afterValue !== null) {
|
|
83
|
+
issues.push({
|
|
84
|
+
type: 'field_type_changed',
|
|
85
|
+
path: path || 'root',
|
|
86
|
+
severity: 'medium',
|
|
87
|
+
breaking: false,
|
|
88
|
+
message: `Type changed from null to ${Array.isArray(afterValue) ? 'array' : typeof afterValue}`
|
|
89
|
+
});
|
|
90
|
+
return issues;
|
|
91
|
+
}
|
|
92
|
+
if (beforeValue !== null && afterValue === null) {
|
|
93
|
+
issues.push({
|
|
94
|
+
type: 'field_type_changed',
|
|
95
|
+
path: path || 'root',
|
|
96
|
+
severity: 'medium',
|
|
97
|
+
breaking: false,
|
|
98
|
+
message: `Type changed from ${Array.isArray(beforeValue) ? 'array' : typeof beforeValue} to null`
|
|
99
|
+
});
|
|
100
|
+
return issues;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const beforeType = Array.isArray(beforeValue) ? 'array' : typeof beforeValue;
|
|
104
|
+
const afterType = Array.isArray(afterValue) ? 'array' : typeof afterValue;
|
|
105
|
+
|
|
106
|
+
if (beforeType !== afterType) {
|
|
107
|
+
issues.push({
|
|
108
|
+
type: 'field_type_changed',
|
|
109
|
+
path: path || 'root',
|
|
110
|
+
severity: 'high',
|
|
111
|
+
breaking: true,
|
|
112
|
+
message: `Type changed from ${beforeType} to ${afterType}`
|
|
113
|
+
});
|
|
114
|
+
return issues;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (isObject(beforeValue) && isObject(afterValue)) {
|
|
118
|
+
const beforeKeys = Object.keys(beforeValue).sort((a, b) => a.localeCompare(b));
|
|
119
|
+
const afterKeys = Object.keys(afterValue).sort((a, b) => a.localeCompare(b));
|
|
120
|
+
|
|
121
|
+
const removed = beforeKeys.filter((key) => !afterKeys.includes(key));
|
|
122
|
+
const added = afterKeys.filter((key) => !beforeKeys.includes(key));
|
|
123
|
+
const matchedRemoved = new Set();
|
|
124
|
+
const matchedAdded = new Set();
|
|
125
|
+
|
|
126
|
+
for (const removedKey of removed) {
|
|
127
|
+
const removedNorm = normalizeFieldName(removedKey);
|
|
128
|
+
const addCandidate = added.find((addedKey) => !matchedAdded.has(addedKey) && normalizeFieldName(addedKey) === removedNorm);
|
|
129
|
+
|
|
130
|
+
if (addCandidate) {
|
|
131
|
+
matchedRemoved.add(removedKey);
|
|
132
|
+
matchedAdded.add(addCandidate);
|
|
133
|
+
issues.push({
|
|
134
|
+
type: 'field_rename_candidate',
|
|
135
|
+
path: path || 'root',
|
|
136
|
+
severity: 'medium',
|
|
137
|
+
breaking: true,
|
|
138
|
+
message: `Possible rename: "${removedKey}" -> "${addCandidate}"`
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const removedKey of removed) {
|
|
144
|
+
if (matchedRemoved.has(removedKey)) continue;
|
|
145
|
+
const removedPath = path ? `${path}.${removedKey}` : removedKey;
|
|
146
|
+
issues.push({
|
|
147
|
+
type: 'field_removed',
|
|
148
|
+
path: removedPath,
|
|
149
|
+
severity: 'high',
|
|
150
|
+
breaking: true,
|
|
151
|
+
message: `Field removed: ${removedPath}`
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const addedKey of added) {
|
|
156
|
+
if (matchedAdded.has(addedKey)) continue;
|
|
157
|
+
const addedPath = path ? `${path}.${addedKey}` : addedKey;
|
|
158
|
+
issues.push({
|
|
159
|
+
type: 'field_added',
|
|
160
|
+
path: addedPath,
|
|
161
|
+
severity: 'low',
|
|
162
|
+
breaking: false,
|
|
163
|
+
message: `Field added: ${addedPath}`
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const sharedKey of beforeKeys.filter((key) => afterKeys.includes(key))) {
|
|
168
|
+
const childPath = path ? `${path}.${sharedKey}` : sharedKey;
|
|
169
|
+
issues.push(...compareBodies(beforeValue[sharedKey], afterValue[sharedKey], childPath));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return issues;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (Array.isArray(beforeValue) && Array.isArray(afterValue)) {
|
|
176
|
+
if (beforeValue.length > 0 && afterValue.length > 0) {
|
|
177
|
+
const itemPath = `${path || 'root'}[0]`;
|
|
178
|
+
issues.push(...compareBodies(beforeValue[0], afterValue[0], itemPath));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return issues;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function updateSummaryFromIssue(summary, issue) {
|
|
186
|
+
summary.totalIssues += 1;
|
|
187
|
+
summary.bySeverity[issue.severity] += 1;
|
|
188
|
+
summary.byType[issue.type] = (summary.byType[issue.type] || 0) + 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ensureEndpoint(reportMap, method, path) {
|
|
192
|
+
const key = endpointKey(method, path);
|
|
193
|
+
if (!reportMap.has(key)) {
|
|
194
|
+
reportMap.set(key, {
|
|
195
|
+
method: String(method || '').toUpperCase(),
|
|
196
|
+
path,
|
|
197
|
+
issues: [],
|
|
198
|
+
highestSeverity: 'none'
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return reportMap.get(key);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function addIssue(reportMap, summary, method, path, issue) {
|
|
205
|
+
const endpoint = ensureEndpoint(reportMap, method, path);
|
|
206
|
+
const isDuplicate = endpoint.issues.some((existing) =>
|
|
207
|
+
existing.type === issue.type &&
|
|
208
|
+
existing.path === issue.path &&
|
|
209
|
+
existing.message === issue.message &&
|
|
210
|
+
existing.severity === issue.severity
|
|
211
|
+
);
|
|
212
|
+
if (isDuplicate) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
endpoint.issues.push(issue);
|
|
217
|
+
if (SEVERITY_RANK[issue.severity] > SEVERITY_RANK[endpoint.highestSeverity]) {
|
|
218
|
+
endpoint.highestSeverity = issue.severity;
|
|
219
|
+
}
|
|
220
|
+
updateSummaryFromIssue(summary, issue);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function compareInteractionsForAnalysis(beforeInteractions, afterInteractions) {
|
|
224
|
+
const reportMap = new Map();
|
|
225
|
+
const summary = {
|
|
226
|
+
totalEndpoints: 0,
|
|
227
|
+
totalIssues: 0,
|
|
228
|
+
bySeverity: {
|
|
229
|
+
low: 0,
|
|
230
|
+
medium: 0,
|
|
231
|
+
high: 0,
|
|
232
|
+
critical: 0
|
|
233
|
+
},
|
|
234
|
+
byType: {}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const sortedBefore = [...beforeInteractions].sort((a, b) => endpointKey(a.method, getPath(a.url)).localeCompare(endpointKey(b.method, getPath(b.url))));
|
|
238
|
+
const sortedAfter = [...afterInteractions].sort((a, b) => endpointKey(a.method, getPath(a.url)).localeCompare(endpointKey(b.method, getPath(b.url))));
|
|
239
|
+
const consumedBeforeIndexes = new Set();
|
|
240
|
+
|
|
241
|
+
for (const after of sortedAfter) {
|
|
242
|
+
const method = String(after.method || '').toUpperCase();
|
|
243
|
+
const path = getPath(after.url || '');
|
|
244
|
+
const candidates = sortedBefore
|
|
245
|
+
.map((item, index) => ({ item, index }))
|
|
246
|
+
.filter(({ item, index }) => !consumedBeforeIndexes.has(index) && String(item.method || '').toUpperCase() === method && getPath(item.url || '') === path);
|
|
247
|
+
|
|
248
|
+
const match = candidates.find(({ item }) => item.id && after.id && item.id === after.id) || candidates[0];
|
|
249
|
+
|
|
250
|
+
if (!match) {
|
|
251
|
+
addIssue(reportMap, summary, method, path, {
|
|
252
|
+
type: 'endpoint_added',
|
|
253
|
+
path,
|
|
254
|
+
severity: 'low',
|
|
255
|
+
breaking: false,
|
|
256
|
+
message: 'Endpoint added in target snapshot'
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
consumedBeforeIndexes.add(match.index);
|
|
262
|
+
const before = match.item;
|
|
263
|
+
const statusIssue = compareStatus(before.response?.status, after.response?.status);
|
|
264
|
+
if (statusIssue) {
|
|
265
|
+
addIssue(reportMap, summary, method, path, statusIssue);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (before.response?.body !== undefined && after.response?.body !== undefined) {
|
|
269
|
+
const bodyIssues = compareBodies(before.response.body, after.response.body);
|
|
270
|
+
for (const issue of bodyIssues) {
|
|
271
|
+
addIssue(reportMap, summary, method, path, issue);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
sortedBefore.forEach((before, index) => {
|
|
277
|
+
if (consumedBeforeIndexes.has(index)) return;
|
|
278
|
+
const method = String(before.method || '').toUpperCase();
|
|
279
|
+
const path = getPath(before.url || '');
|
|
280
|
+
addIssue(reportMap, summary, method, path, {
|
|
281
|
+
type: 'endpoint_removed',
|
|
282
|
+
path,
|
|
283
|
+
severity: 'high',
|
|
284
|
+
breaking: true,
|
|
285
|
+
message: 'Endpoint removed from target snapshot'
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const endpoints = Array.from(reportMap.values())
|
|
290
|
+
.sort((a, b) => {
|
|
291
|
+
const severityDiff = SEVERITY_RANK[b.highestSeverity] - SEVERITY_RANK[a.highestSeverity];
|
|
292
|
+
if (severityDiff !== 0) return severityDiff;
|
|
293
|
+
if (a.method !== b.method) return a.method.localeCompare(b.method);
|
|
294
|
+
return a.path.localeCompare(b.path);
|
|
295
|
+
})
|
|
296
|
+
.map((endpoint) => ({
|
|
297
|
+
...endpoint,
|
|
298
|
+
issues: endpoint.issues.sort((a, b) => {
|
|
299
|
+
const severityDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
|
|
300
|
+
if (severityDiff !== 0) return severityDiff;
|
|
301
|
+
if (a.type !== b.type) return a.type.localeCompare(b.type);
|
|
302
|
+
return String(a.path || '').localeCompare(String(b.path || ''));
|
|
303
|
+
})
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
summary.totalEndpoints = endpoints.length;
|
|
307
|
+
const highestSeverity = endpoints.reduce((highest, endpoint) => (
|
|
308
|
+
SEVERITY_RANK[endpoint.highestSeverity] > SEVERITY_RANK[highest] ? endpoint.highestSeverity : highest
|
|
309
|
+
), 'none');
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
summary,
|
|
313
|
+
highestSeverity,
|
|
314
|
+
endpoints
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function analyzeSnapshotData(sourceSnapshot, targetSnapshot) {
|
|
319
|
+
const sourceInteractions = sourceSnapshot?.interactions || [];
|
|
320
|
+
const targetInteractions = targetSnapshot?.interactions || [];
|
|
321
|
+
const analysis = compareInteractionsForAnalysis(sourceInteractions, targetInteractions);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
generatedAt: new Date().toISOString(),
|
|
325
|
+
source: {
|
|
326
|
+
tag: sourceSnapshot?.tag || 'source',
|
|
327
|
+
timestamp: sourceSnapshot?.timestamp
|
|
328
|
+
},
|
|
329
|
+
target: {
|
|
330
|
+
tag: targetSnapshot?.tag || 'target',
|
|
331
|
+
timestamp: targetSnapshot?.timestamp
|
|
332
|
+
},
|
|
333
|
+
summary: analysis.summary,
|
|
334
|
+
highestSeverity: analysis.highestSeverity,
|
|
335
|
+
endpoints: analysis.endpoints
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function colorForSeverity(severity) {
|
|
340
|
+
if (severity === 'critical') return chalk.redBright;
|
|
341
|
+
if (severity === 'high') return chalk.red;
|
|
342
|
+
if (severity === 'medium') return chalk.yellow;
|
|
343
|
+
if (severity === 'low') return chalk.blue;
|
|
344
|
+
return chalk.gray;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function shouldFail(report, failOnSeverity) {
|
|
348
|
+
const threshold = String(failOnSeverity || '').toLowerCase();
|
|
349
|
+
if (!SEVERITY_RANK[threshold]) return false;
|
|
350
|
+
return SEVERITY_RANK[report.highestSeverity] >= SEVERITY_RANK[threshold];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function maybeTranslate(text, lang) {
|
|
354
|
+
if (!lang) return text;
|
|
355
|
+
return translateText(text, lang);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function requestGroqSummary(report, { model, maxTokens, apiKey }) {
|
|
359
|
+
const endpointPreview = report.endpoints.slice(0, 30).map((endpoint) => ({
|
|
360
|
+
method: endpoint.method,
|
|
361
|
+
path: endpoint.path,
|
|
362
|
+
highestSeverity: endpoint.highestSeverity,
|
|
363
|
+
issues: endpoint.issues.slice(0, 6).map((issue) => ({
|
|
364
|
+
type: issue.type,
|
|
365
|
+
severity: issue.severity,
|
|
366
|
+
message: issue.message
|
|
367
|
+
}))
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
const prompt = [
|
|
371
|
+
'You are a senior API reviewer.',
|
|
372
|
+
'Return concise output only as bullet points.',
|
|
373
|
+
'Rules:',
|
|
374
|
+
'- Maximum 5 bullet points',
|
|
375
|
+
'- Each bullet must be <= 14 words',
|
|
376
|
+
'- Focus on impact + risk + actionable fix',
|
|
377
|
+
'- Do not include headings or paragraphs',
|
|
378
|
+
'',
|
|
379
|
+
stableJSONStringify({
|
|
380
|
+
sourceTag: report.source.tag,
|
|
381
|
+
targetTag: report.target.tag,
|
|
382
|
+
summary: report.summary,
|
|
383
|
+
highestSeverity: report.highestSeverity,
|
|
384
|
+
endpoints: endpointPreview
|
|
385
|
+
}, 2)
|
|
386
|
+
].join('\n');
|
|
387
|
+
|
|
388
|
+
const response = await axios.post(
|
|
389
|
+
'https://api.groq.com/openai/v1/chat/completions',
|
|
390
|
+
{
|
|
391
|
+
model,
|
|
392
|
+
temperature: 0.2,
|
|
393
|
+
max_tokens: maxTokens,
|
|
394
|
+
messages: [
|
|
395
|
+
{ role: 'system', content: 'You are an API contract analysis assistant.' },
|
|
396
|
+
{ role: 'user', content: prompt }
|
|
397
|
+
]
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
headers: {
|
|
401
|
+
Authorization: `Bearer ${apiKey}`,
|
|
402
|
+
'Content-Type': 'application/json'
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const text = response.data?.choices?.[0]?.message?.content;
|
|
408
|
+
if (!text) {
|
|
409
|
+
throw new Error(`No summary text returned by Groq model ${model}`);
|
|
410
|
+
}
|
|
411
|
+
return text.trim();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function toConciseBullets(rawText, report) {
|
|
415
|
+
let cleanedText = String(rawText || '');
|
|
416
|
+
cleanedText = cleanedText.replace(/<think>[\s\S]*?<\/think>/gi, ' ');
|
|
417
|
+
if (cleanedText.includes('<think>')) {
|
|
418
|
+
cleanedText = cleanedText.split('<think>')[0];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const lines = cleanedText
|
|
422
|
+
.split('\n')
|
|
423
|
+
.map((line) => line.trim())
|
|
424
|
+
.filter(Boolean);
|
|
425
|
+
|
|
426
|
+
const normalized = lines
|
|
427
|
+
.map((line) => line.replace(/^[-*โข\d.)\s]+/, '').trim())
|
|
428
|
+
.filter((line) => !/^<\/?think>$/i.test(line))
|
|
429
|
+
.filter((line) => !/^(okay|let'?s|first,|then,|on the flip side|i need to)/i.test(line))
|
|
430
|
+
.filter(Boolean);
|
|
431
|
+
|
|
432
|
+
if (normalized.length === 0 || normalized.every((line) => line.length < 8)) {
|
|
433
|
+
return fallbackBullets(report);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const bullets = normalized
|
|
437
|
+
.slice(0, 5)
|
|
438
|
+
.map((line) => {
|
|
439
|
+
const compact = line.replace(/\s+/g, ' ');
|
|
440
|
+
const clipped = compact.length > 100 ? `${compact.slice(0, 97)}...` : compact;
|
|
441
|
+
return `- ${clipped}`;
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
return bullets;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function fallbackBullets(report) {
|
|
448
|
+
const s = report.summary;
|
|
449
|
+
const bullets = [];
|
|
450
|
+
bullets.push(`- Highest severity: ${String(report.highestSeverity).toUpperCase()}.`);
|
|
451
|
+
bullets.push(`- High/Critical issues: ${s.bySeverity.high + s.bySeverity.critical}.`);
|
|
452
|
+
bullets.push(`- Removed endpoints: ${s.byType.endpoint_removed || 0}.`);
|
|
453
|
+
bullets.push(`- Status changes: ${s.byType.status_changed || 0}.`);
|
|
454
|
+
bullets.push('- Action: patch clients first, then add compatibility aliases.');
|
|
455
|
+
return bullets;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function resolveGroqApiKey() {
|
|
459
|
+
return getEnvVar('GROQ_API_KEY');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function generateAiSummary(report, options) {
|
|
463
|
+
const apiKey = resolveGroqApiKey();
|
|
464
|
+
if (!apiKey) {
|
|
465
|
+
throw new Error('GROQ_API_KEY not found in .kiroo/env.json. Run "kiroo env set GROQ_API_KEY <your_key>" or re-run "kiroo init".');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const modelCandidates = options.model
|
|
469
|
+
? [options.model]
|
|
470
|
+
: (options.modelPriority?.length ? options.modelPriority : DEFAULT_MODEL_PRIORITY);
|
|
471
|
+
|
|
472
|
+
const failures = [];
|
|
473
|
+
for (const model of modelCandidates) {
|
|
474
|
+
try {
|
|
475
|
+
const summary = await requestGroqSummary(report, {
|
|
476
|
+
model,
|
|
477
|
+
maxTokens: options.maxTokens,
|
|
478
|
+
apiKey
|
|
479
|
+
});
|
|
480
|
+
return { summary, model };
|
|
481
|
+
} catch (error) {
|
|
482
|
+
failures.push(`${model}: ${error.message}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
throw new Error(`Groq summary failed for all candidate models. ${failures.join(' | ')}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function analyzeSnapshots(tag1, tag2, options = {}) {
|
|
490
|
+
try {
|
|
491
|
+
const config = loadKirooConfig();
|
|
492
|
+
const failOnSeverity = options.failOn || null;
|
|
493
|
+
const maxTokens = Number.parseInt(options.maxTokens, 10) || config.settings?.analysis?.maxCompletionTokens || 900;
|
|
494
|
+
const modelPriority = config.settings?.analysis?.modelPriority || DEFAULT_MODEL_PRIORITY;
|
|
495
|
+
|
|
496
|
+
const sourceSnapshot = loadSnapshotData(tag1);
|
|
497
|
+
const targetSnapshot = loadSnapshotData(tag2);
|
|
498
|
+
const report = analyzeSnapshotData(sourceSnapshot, targetSnapshot);
|
|
499
|
+
|
|
500
|
+
if (options.json) {
|
|
501
|
+
console.log(stableJSONStringify(report));
|
|
502
|
+
} else {
|
|
503
|
+
let header = '๐ง Blast Radius Analysis';
|
|
504
|
+
if (options.lang) header = await translateText(header, options.lang);
|
|
505
|
+
console.log(chalk.cyan(`\n ${header}`));
|
|
506
|
+
console.log(chalk.gray(` Source: ${tag1}`));
|
|
507
|
+
console.log(chalk.gray(` Target: ${tag2}\n`));
|
|
508
|
+
|
|
509
|
+
const shownEndpoints = report.endpoints.slice(0, 6);
|
|
510
|
+
for (const endpoint of shownEndpoints) {
|
|
511
|
+
let severityLabel = endpoint.highestSeverity.toUpperCase();
|
|
512
|
+
if (options.lang) severityLabel = await translateText(severityLabel, options.lang);
|
|
513
|
+
const severityColor = colorForSeverity(endpoint.highestSeverity);
|
|
514
|
+
console.log(` ${severityColor(severityLabel)} ${chalk.white(endpoint.method)} ${chalk.gray(endpoint.path)}`);
|
|
515
|
+
|
|
516
|
+
for (const issue of endpoint.issues.slice(0, 4)) {
|
|
517
|
+
const issueColor = colorForSeverity(issue.severity);
|
|
518
|
+
let issueMsg = issue.message;
|
|
519
|
+
if (options.lang) issueMsg = await translateText(issueMsg, options.lang);
|
|
520
|
+
console.log(` - ${issueColor(issue.severity)} ${issueMsg}`);
|
|
521
|
+
}
|
|
522
|
+
if (endpoint.issues.length > 4) {
|
|
523
|
+
console.log(chalk.gray(` - ... +${endpoint.issues.length - 4} more issues`));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (report.endpoints.length > shownEndpoints.length) {
|
|
527
|
+
console.log(chalk.gray(` ... +${report.endpoints.length - shownEndpoints.length} more endpoints`));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const summaryLine = `Issues: ${report.summary.totalIssues} | Endpoints: ${report.summary.totalEndpoints} | Highest severity: ${report.highestSeverity.toUpperCase()}`;
|
|
531
|
+
const translatedSummary = await maybeTranslate(summaryLine, options.lang);
|
|
532
|
+
console.log(chalk.cyan(`\n ${translatedSummary}`));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (options.ai) {
|
|
536
|
+
const ai = await generateAiSummary(report, {
|
|
537
|
+
model: options.model,
|
|
538
|
+
modelPriority,
|
|
539
|
+
maxTokens
|
|
540
|
+
});
|
|
541
|
+
let aiSectionTitle = `๐ค AI Summary (${ai.model})`;
|
|
542
|
+
if (options.lang) aiSectionTitle = await translateText(aiSectionTitle, options.lang);
|
|
543
|
+
console.log(chalk.magenta(`\n ${aiSectionTitle}`));
|
|
544
|
+
|
|
545
|
+
const bullets = toConciseBullets(ai.summary, report);
|
|
546
|
+
for (const bullet of bullets) {
|
|
547
|
+
let finalBullet = bullet;
|
|
548
|
+
if (options.lang) {
|
|
549
|
+
// Translate the text part of the bullet
|
|
550
|
+
const bulletText = bullet.replace(/^- /, '');
|
|
551
|
+
const translatedBullet = await translateText(bulletText, options.lang);
|
|
552
|
+
finalBullet = `- ${translatedBullet}`;
|
|
553
|
+
}
|
|
554
|
+
console.log(` ${finalBullet}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (failOnSeverity && shouldFail(report, failOnSeverity)) {
|
|
559
|
+
console.error(chalk.red(`\n โ Analysis failed threshold (${failOnSeverity}). Highest severity is ${report.highestSeverity}.`));
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
console.log(chalk.green('\n โ
Analysis complete.\n'));
|
|
564
|
+
} catch (error) {
|
|
565
|
+
console.error(chalk.red('\n โ Analysis failed:'), error.message, '\n');
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
}
|
package/src/bench.js
CHANGED
|
@@ -4,6 +4,7 @@ import axios from 'axios';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import { loadEnv } from './storage.js';
|
|
6
6
|
import { applyEnvReplacements } from './executor.js';
|
|
7
|
+
import { translateText } from './lingo.js';
|
|
7
8
|
|
|
8
9
|
export async function runBenchmark(url, options) {
|
|
9
10
|
const envData = loadEnv();
|
|
@@ -177,7 +178,7 @@ export async function runBenchmark(url, options) {
|
|
|
177
178
|
}
|
|
178
179
|
};
|
|
179
180
|
|
|
180
|
-
const finalizeBenchmark = () => {
|
|
181
|
+
const finalizeBenchmark = async () => {
|
|
181
182
|
const totalTime = Date.now() - startTime;
|
|
182
183
|
if (!isVerbose) spinner.stop();
|
|
183
184
|
|
|
@@ -191,7 +192,9 @@ export async function runBenchmark(url, options) {
|
|
|
191
192
|
avg = Math.round(results.times.reduce((a, b) => a + b, 0) / results.times.length);
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
|
|
195
|
+
let resultTitle = '๐ Benchmark Results';
|
|
196
|
+
if (options.lang) resultTitle = await translateText(resultTitle, options.lang);
|
|
197
|
+
console.log('\n ' + chalk.blue.bold(resultTitle));
|
|
195
198
|
console.log(' ' + chalk.gray(`${method} ${targetUrl}\n`));
|
|
196
199
|
|
|
197
200
|
const statsTable = new Table({
|
|
@@ -212,9 +215,13 @@ export async function runBenchmark(url, options) {
|
|
|
212
215
|
console.log(statsTable.toString());
|
|
213
216
|
|
|
214
217
|
if (results.failures > 0) {
|
|
215
|
-
|
|
218
|
+
let errorMsg = `โ ๏ธ ${results.failures} requests failed (HTTP 4xx/5xx or Network Error).`;
|
|
219
|
+
if (options.lang) errorMsg = await translateText(errorMsg, options.lang);
|
|
220
|
+
console.log(chalk.red(`\n ${errorMsg}\n`));
|
|
216
221
|
} else {
|
|
217
|
-
|
|
222
|
+
let successMsg = 'โ
All requests completed successfully.';
|
|
223
|
+
if (options.lang) successMsg = await translateText(successMsg, options.lang);
|
|
224
|
+
console.log(chalk.green(`\n ${successMsg}\n`));
|
|
218
225
|
}
|
|
219
226
|
|
|
220
227
|
resolve();
|
package/src/checker.js
CHANGED
|
@@ -76,24 +76,41 @@ function getDeep(obj, path) {
|
|
|
76
76
|
return keys.reduce((acc, key) => acc && acc[key], obj);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
import { translateText } from './lingo.js';
|
|
80
|
+
|
|
81
|
+
export async function showCheckResult(validation, lang) {
|
|
82
|
+
let title = '๐งช Test Results:';
|
|
83
|
+
if (lang) title = await translateText(title, lang);
|
|
84
|
+
console.log(chalk.cyan(`\n ${title}`));
|
|
81
85
|
|
|
82
|
-
validation.results
|
|
86
|
+
for (const res of validation.results) {
|
|
83
87
|
const icon = res.passed ? chalk.green('โ') : chalk.red('โ');
|
|
84
88
|
const color = res.passed ? chalk.white : chalk.red;
|
|
85
89
|
|
|
86
|
-
|
|
90
|
+
let label = res.label;
|
|
91
|
+
if (lang) label = await translateText(label, lang);
|
|
92
|
+
console.log(` ${icon} ${label}`);
|
|
93
|
+
|
|
87
94
|
if (!res.passed) {
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
let expectedLabel = 'Expected:';
|
|
96
|
+
let actualLabel = 'Actual:';
|
|
97
|
+
if (lang) {
|
|
98
|
+
expectedLabel = await translateText(expectedLabel, lang);
|
|
99
|
+
actualLabel = await translateText(actualLabel, lang);
|
|
100
|
+
}
|
|
101
|
+
console.log(chalk.gray(` ${expectedLabel} ${res.expected}`));
|
|
102
|
+
console.log(chalk.gray(` ${actualLabel} ${res.actual}`));
|
|
90
103
|
}
|
|
91
|
-
}
|
|
104
|
+
}
|
|
92
105
|
|
|
93
106
|
if (validation.passed) {
|
|
94
|
-
|
|
107
|
+
let msg = 'โจ ALL TESTS PASSED!';
|
|
108
|
+
if (lang) msg = await translateText(msg, lang);
|
|
109
|
+
console.log(chalk.green.bold(`\n ${msg} \n`));
|
|
95
110
|
} else {
|
|
96
|
-
|
|
111
|
+
let msg = 'โ SOME TESTS FAILED';
|
|
112
|
+
if (lang) msg = await translateText(msg, lang);
|
|
113
|
+
console.log(chalk.red.bold(`\n ${msg} \n`));
|
|
97
114
|
process.exit(1);
|
|
98
115
|
}
|
|
99
116
|
}
|