artifacty 0.10.8 → 0.11.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/CLAUDE.md +1 -1
- package/README.md +260 -5
- package/docs/artifact-schema-v1.md +187 -1
- package/docs/central-team-deployment-design.md +3 -1
- package/docs/integrations.md +9 -1
- package/docs/mcp-public-api.md +103 -9
- package/docs/release-checklist.md +1 -1
- package/docs/roadmap-design.md +977 -0
- package/docs/sarif-csv-artifact-plan.md +42 -7
- package/docs/threat-model.md +217 -2
- package/package.json +2 -1
- package/src/cli.js +550 -43
- package/src/client/viewer.js +377 -0
- package/src/lib/backup.js +326 -15
- package/src/lib/converters.js +99 -5
- package/src/lib/csv.js +51 -0
- package/src/lib/diff.js +656 -0
- package/src/lib/doctor.js +88 -2
- package/src/lib/embeddings.js +407 -0
- package/src/lib/events.js +183 -0
- package/src/lib/i18n.js +290 -2
- package/src/lib/listing.js +46 -0
- package/src/lib/openapi.js +614 -0
- package/src/lib/render.js +1604 -160
- package/src/lib/retention-form.js +31 -0
- package/src/lib/retention.js +499 -0
- package/src/lib/sarif-csv-export.js +290 -0
- package/src/lib/schemas.js +461 -0
- package/src/lib/security.js +226 -1
- package/src/lib/sse.js +52 -0
- package/src/lib/storage.js +2935 -305
- package/src/lib/webhooks.js +356 -0
- package/src/mcp-server.js +756 -25
- package/src/server.js +1529 -98
package/src/lib/diff.js
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
import { detectFormat } from "./converters.js";
|
|
2
|
+
import { parseCsv } from "./csv.js";
|
|
3
|
+
|
|
4
|
+
const ARRAY_MATCH_KEYS = ["id", "ruleId", "path"];
|
|
5
|
+
const DEFAULT_MAX_DIFF_ENTRIES = 5000;
|
|
6
|
+
|
|
7
|
+
// Formats createStructuredDiff renders structurally rather than as a plain
|
|
8
|
+
// line diff. Shared by server.js, cli.js, and mcp-server.js so the three
|
|
9
|
+
// adapters can't drift on which formats count as "structured" (they had
|
|
10
|
+
// triplicated this Set before).
|
|
11
|
+
export const STRUCTURED_DIFF_FORMATS = new Set(["json", "sarif", "csv", "notebook", "bundle"]);
|
|
12
|
+
|
|
13
|
+
// The diff format to use for a given artifact type/version-format pair:
|
|
14
|
+
// bundle artifacts always diff per-file regardless of the version's own
|
|
15
|
+
// stored format (a bundle version's `format` is the container format, not
|
|
16
|
+
// any one file's), everything else diffs using its version format
|
|
17
|
+
// directly.
|
|
18
|
+
export function diffFormatFor(artifactType, format) {
|
|
19
|
+
if (artifactType === "bundle") {
|
|
20
|
+
return "bundle";
|
|
21
|
+
}
|
|
22
|
+
return format;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolves the requested diff view ("structured" | "lines" | anything else,
|
|
26
|
+
// meaning "let the format decide") the same way across all three adapters.
|
|
27
|
+
export function resolveDiffView({ artifactType, format, requestedView }) {
|
|
28
|
+
if (requestedView === "structured" || requestedView === "lines") {
|
|
29
|
+
return requestedView;
|
|
30
|
+
}
|
|
31
|
+
const diffFormat = diffFormatFor(artifactType, format);
|
|
32
|
+
return STRUCTURED_DIFF_FORMATS.has(diffFormat) ? "structured" : "lines";
|
|
33
|
+
}
|
|
34
|
+
|
|
1
35
|
export function createLineDiff(before, after, options = {}) {
|
|
2
36
|
const beforeLines = splitLines(before);
|
|
3
37
|
const afterLines = splitLines(after);
|
|
@@ -67,3 +101,625 @@ export function createLineDiff(before, after, options = {}) {
|
|
|
67
101
|
function splitLines(value) {
|
|
68
102
|
return String(value).split(/\r?\n/);
|
|
69
103
|
}
|
|
104
|
+
|
|
105
|
+
// --- Structured diff -----------------------------------------------------
|
|
106
|
+
//
|
|
107
|
+
// createStructuredDiff(before, after, { format, maxEntries }) picks a
|
|
108
|
+
// per-format diff strategy and returns { kind, entries, truncated, summary }.
|
|
109
|
+
// renderUnifiedDiffText(structuredDiff) turns that structure into a compact
|
|
110
|
+
// human-readable text block for surfaces (MCP content[].text) that want a
|
|
111
|
+
// plain-text rendering instead of the structure itself.
|
|
112
|
+
|
|
113
|
+
export function createStructuredDiff(before, after, options = {}) {
|
|
114
|
+
const cap = normalizeMaxEntries(options.maxEntries);
|
|
115
|
+
const format = options.format || "text";
|
|
116
|
+
const beforeStr = before == null ? "" : String(before);
|
|
117
|
+
const afterStr = after == null ? "" : String(after);
|
|
118
|
+
|
|
119
|
+
if (format === "json" || format === "sarif" || format === "notebook") {
|
|
120
|
+
return buildJsonKindDiff(beforeStr, afterStr, cap);
|
|
121
|
+
}
|
|
122
|
+
if (format === "csv") {
|
|
123
|
+
const csvResult = buildCsvKindDiff(beforeStr, afterStr, cap);
|
|
124
|
+
if (csvResult) {
|
|
125
|
+
return csvResult;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (format === "bundle") {
|
|
129
|
+
const bundleResult = buildBundleKindDiff(beforeStr, afterStr, cap);
|
|
130
|
+
if (bundleResult) {
|
|
131
|
+
return bundleResult;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return buildLineKindDiff(beforeStr, afterStr, cap);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderUnifiedDiffText(structuredDiff) {
|
|
138
|
+
if (!structuredDiff || !Array.isArray(structuredDiff.entries)) {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = [];
|
|
143
|
+
for (const entry of structuredDiff.entries) {
|
|
144
|
+
lines.push(...renderEntryLines(structuredDiff.kind, entry));
|
|
145
|
+
}
|
|
146
|
+
if (structuredDiff.truncated) {
|
|
147
|
+
lines.push(`... diff truncated at ${structuredDiff.entries.length} entries ...`);
|
|
148
|
+
}
|
|
149
|
+
return lines.join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderEntryLines(kind, entry) {
|
|
153
|
+
if (kind === "lines") {
|
|
154
|
+
if (entry.op === "same") {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
if (entry.op === "removed") {
|
|
158
|
+
return [`- ${entry.text}`];
|
|
159
|
+
}
|
|
160
|
+
if (entry.op === "added") {
|
|
161
|
+
return [`+ ${entry.text}`];
|
|
162
|
+
}
|
|
163
|
+
return [`- ${entry.before}`, `+ ${entry.after}`];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (kind === "json") {
|
|
167
|
+
if (entry.op === "added") {
|
|
168
|
+
return [`+ ${entry.path} = ${stringifyValue(entry.after)}`];
|
|
169
|
+
}
|
|
170
|
+
if (entry.op === "removed") {
|
|
171
|
+
return [`- ${entry.path} = ${stringifyValue(entry.before)}`];
|
|
172
|
+
}
|
|
173
|
+
return [`~ ${entry.path}: ${stringifyValue(entry.before)} -> ${stringifyValue(entry.after)}`];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (kind === "csv") {
|
|
177
|
+
const rowLabel = entry.row === "header" ? "header" : `row ${entry.row}`;
|
|
178
|
+
if (entry.op === "added") {
|
|
179
|
+
return [`+ ${rowLabel}: ${JSON.stringify(entry.cells)}`];
|
|
180
|
+
}
|
|
181
|
+
if (entry.op === "removed") {
|
|
182
|
+
return [`- ${rowLabel}: ${JSON.stringify(entry.cells)}`];
|
|
183
|
+
}
|
|
184
|
+
return [`~ ${rowLabel} col ${entry.column}: ${stringifyValue(entry.before)} -> ${stringifyValue(entry.after)}`];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (kind === "bundle") {
|
|
188
|
+
const prefix = entry.file ? `${entry.file}: ` : "";
|
|
189
|
+
if (entry.op === "added" && entry.path === undefined && entry.row === undefined) {
|
|
190
|
+
return [`+ ${prefix}(file added)`];
|
|
191
|
+
}
|
|
192
|
+
if (entry.op === "removed" && entry.path === undefined && entry.row === undefined) {
|
|
193
|
+
return [`- ${prefix}(file removed)`];
|
|
194
|
+
}
|
|
195
|
+
const inner = renderEntryLines(entry.kind || "lines", entry);
|
|
196
|
+
if (inner.length > 0) {
|
|
197
|
+
return inner.map((line) => `${prefix}${line}`);
|
|
198
|
+
}
|
|
199
|
+
return [`~ ${prefix}${entry.path || entry.row || ""}`];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stringifyValue(value) {
|
|
206
|
+
if (value === undefined) {
|
|
207
|
+
return "undefined";
|
|
208
|
+
}
|
|
209
|
+
if (typeof value === "string") {
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
return JSON.stringify(value);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function normalizeMaxEntries(maxEntries) {
|
|
216
|
+
if (Number.isFinite(maxEntries) && maxEntries > 0) {
|
|
217
|
+
return Math.floor(maxEntries);
|
|
218
|
+
}
|
|
219
|
+
const fromEnv = Number(process.env.ARTIFACTY_MAX_DIFF_ENTRIES);
|
|
220
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0) {
|
|
221
|
+
return Math.floor(fromEnv);
|
|
222
|
+
}
|
|
223
|
+
return DEFAULT_MAX_DIFF_ENTRIES;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function createDiffState(cap) {
|
|
227
|
+
return { entries: [], counters: { added: 0, removed: 0, changed: 0 }, truncated: false, cap };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pushEntry(state, entry) {
|
|
231
|
+
if (state.entries.length >= state.cap) {
|
|
232
|
+
state.truncated = true;
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
state.entries.push(entry);
|
|
236
|
+
if (entry.op !== "same" && Object.prototype.hasOwnProperty.call(state.counters, entry.op)) {
|
|
237
|
+
state.counters[entry.op] += 1;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Line diff + word-level highlighting ---------------------------------
|
|
243
|
+
|
|
244
|
+
function buildLineKindDiff(beforeStr, afterStr, cap) {
|
|
245
|
+
const rows = createLineDiff(beforeStr, afterStr);
|
|
246
|
+
const state = createDiffState(cap);
|
|
247
|
+
let i = 0;
|
|
248
|
+
|
|
249
|
+
while (i < rows.length && !state.truncated) {
|
|
250
|
+
const row = rows[i];
|
|
251
|
+
|
|
252
|
+
if (row.type === "same") {
|
|
253
|
+
pushEntry(state, { op: "same", beforeLine: row.beforeLine, afterLine: row.afterLine, text: row.text });
|
|
254
|
+
i += 1;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (row.type === "removed") {
|
|
259
|
+
const removedBlock = [];
|
|
260
|
+
let j = i;
|
|
261
|
+
while (j < rows.length && rows[j].type === "removed") {
|
|
262
|
+
removedBlock.push(rows[j]);
|
|
263
|
+
j += 1;
|
|
264
|
+
}
|
|
265
|
+
const addedBlock = [];
|
|
266
|
+
let k = j;
|
|
267
|
+
while (k < rows.length && rows[k].type === "added") {
|
|
268
|
+
addedBlock.push(rows[k]);
|
|
269
|
+
k += 1;
|
|
270
|
+
}
|
|
271
|
+
const pairCount = Math.min(removedBlock.length, addedBlock.length);
|
|
272
|
+
for (let p = 0; p < pairCount && !state.truncated; p += 1) {
|
|
273
|
+
const b = removedBlock[p];
|
|
274
|
+
const a = addedBlock[p];
|
|
275
|
+
pushEntry(state, {
|
|
276
|
+
op: "changed",
|
|
277
|
+
beforeLine: b.beforeLine,
|
|
278
|
+
afterLine: a.afterLine,
|
|
279
|
+
before: b.text,
|
|
280
|
+
after: a.text,
|
|
281
|
+
words: diffWords(b.text, a.text)
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
for (let p = pairCount; p < removedBlock.length && !state.truncated; p += 1) {
|
|
285
|
+
pushEntry(state, { op: "removed", beforeLine: removedBlock[p].beforeLine, text: removedBlock[p].text });
|
|
286
|
+
}
|
|
287
|
+
for (let p = pairCount; p < addedBlock.length && !state.truncated; p += 1) {
|
|
288
|
+
pushEntry(state, { op: "added", afterLine: addedBlock[p].afterLine, text: addedBlock[p].text });
|
|
289
|
+
}
|
|
290
|
+
i = k;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Standalone "added" run with no preceding "removed" run.
|
|
295
|
+
const addedBlock = [];
|
|
296
|
+
let j = i;
|
|
297
|
+
while (j < rows.length && rows[j].type === "added") {
|
|
298
|
+
addedBlock.push(rows[j]);
|
|
299
|
+
j += 1;
|
|
300
|
+
}
|
|
301
|
+
for (const r of addedBlock) {
|
|
302
|
+
if (state.truncated) {
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
pushEntry(state, { op: "added", afterLine: r.afterLine, text: r.text });
|
|
306
|
+
}
|
|
307
|
+
i = j;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { kind: "lines", entries: state.entries, truncated: state.truncated, summary: { ...state.counters } };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function diffWords(beforeText, afterText) {
|
|
314
|
+
const beforeTokens = tokenizeWords(beforeText);
|
|
315
|
+
const afterTokens = tokenizeWords(afterText);
|
|
316
|
+
const maxCells = 20000;
|
|
317
|
+
|
|
318
|
+
if (beforeTokens.length * afterTokens.length > maxCells) {
|
|
319
|
+
return [
|
|
320
|
+
...beforeTokens.map((text) => ({ op: "removed", text })),
|
|
321
|
+
...afterTokens.map((text) => ({ op: "added", text }))
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const width = afterTokens.length + 1;
|
|
326
|
+
const table = new Uint32Array((beforeTokens.length + 1) * width);
|
|
327
|
+
|
|
328
|
+
for (let i = beforeTokens.length - 1; i >= 0; i -= 1) {
|
|
329
|
+
for (let j = afterTokens.length - 1; j >= 0; j -= 1) {
|
|
330
|
+
const offset = i * width + j;
|
|
331
|
+
if (beforeTokens[i] === afterTokens[j]) {
|
|
332
|
+
table[offset] = table[(i + 1) * width + j + 1] + 1;
|
|
333
|
+
} else {
|
|
334
|
+
table[offset] = Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const words = [];
|
|
340
|
+
let i = 0;
|
|
341
|
+
let j = 0;
|
|
342
|
+
while (i < beforeTokens.length && j < afterTokens.length) {
|
|
343
|
+
if (beforeTokens[i] === afterTokens[j]) {
|
|
344
|
+
words.push({ op: "same", text: beforeTokens[i] });
|
|
345
|
+
i += 1;
|
|
346
|
+
j += 1;
|
|
347
|
+
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
|
348
|
+
words.push({ op: "removed", text: beforeTokens[i] });
|
|
349
|
+
i += 1;
|
|
350
|
+
} else {
|
|
351
|
+
words.push({ op: "added", text: afterTokens[j] });
|
|
352
|
+
j += 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
while (i < beforeTokens.length) {
|
|
356
|
+
words.push({ op: "removed", text: beforeTokens[i] });
|
|
357
|
+
i += 1;
|
|
358
|
+
}
|
|
359
|
+
while (j < afterTokens.length) {
|
|
360
|
+
words.push({ op: "added", text: afterTokens[j] });
|
|
361
|
+
j += 1;
|
|
362
|
+
}
|
|
363
|
+
return words;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function tokenizeWords(value) {
|
|
367
|
+
return String(value).match(/\s+|[^\s]+/g) || [];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- JSON / SARIF / notebook diff (recursive, JSON-path keyed) -----------
|
|
371
|
+
|
|
372
|
+
function buildJsonKindDiff(beforeStr, afterStr, cap) {
|
|
373
|
+
let beforeValue;
|
|
374
|
+
let afterValue;
|
|
375
|
+
try {
|
|
376
|
+
beforeValue = beforeStr.trim() === "" ? undefined : JSON.parse(beforeStr);
|
|
377
|
+
afterValue = afterStr.trim() === "" ? undefined : JSON.parse(afterStr);
|
|
378
|
+
} catch {
|
|
379
|
+
return buildLineKindDiff(beforeStr, afterStr, cap);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const state = createDiffState(cap);
|
|
383
|
+
diffJsonValue("$", beforeValue, afterValue, state);
|
|
384
|
+
return { kind: "json", entries: state.entries, truncated: state.truncated, summary: { ...state.counters } };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function diffJsonValue(path, before, after, state) {
|
|
388
|
+
if (state.truncated) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const beforeDefined = before !== undefined;
|
|
392
|
+
const afterDefined = after !== undefined;
|
|
393
|
+
|
|
394
|
+
if (!beforeDefined && !afterDefined) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!beforeDefined) {
|
|
398
|
+
pushEntry(state, { op: "added", path, after });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!afterDefined) {
|
|
402
|
+
pushEntry(state, { op: "removed", path, before });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
406
|
+
diffJsonArray(path, before, after, state);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
410
|
+
diffJsonObject(path, before, after, state);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (!deepEqual(before, after)) {
|
|
414
|
+
pushEntry(state, { op: "changed", path, before, after });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function diffJsonObject(path, before, after, state) {
|
|
419
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
420
|
+
for (const key of keys) {
|
|
421
|
+
if (state.truncated) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
diffJsonValue(appendJsonPath(path, key), before[key], after[key], state);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function diffJsonArray(path, before, after, state) {
|
|
429
|
+
const matchKey = findArrayMatchKey(before, after);
|
|
430
|
+
|
|
431
|
+
if (matchKey) {
|
|
432
|
+
const beforeByKey = new Map(before.map((item, index) => [item[matchKey], { item, index }]));
|
|
433
|
+
const afterByKey = new Map(after.map((item, index) => [item[matchKey], { item, index }]));
|
|
434
|
+
const order = [];
|
|
435
|
+
const seen = new Set();
|
|
436
|
+
for (const item of before) {
|
|
437
|
+
const key = item[matchKey];
|
|
438
|
+
if (!seen.has(key)) {
|
|
439
|
+
seen.add(key);
|
|
440
|
+
order.push(key);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const item of after) {
|
|
444
|
+
const key = item[matchKey];
|
|
445
|
+
if (!seen.has(key)) {
|
|
446
|
+
seen.add(key);
|
|
447
|
+
order.push(key);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const key of order) {
|
|
451
|
+
if (state.truncated) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const beforeEntry = beforeByKey.get(key);
|
|
455
|
+
const afterEntry = afterByKey.get(key);
|
|
456
|
+
const index = beforeEntry ? beforeEntry.index : afterEntry.index;
|
|
457
|
+
diffJsonValue(`${path}[${index}]`, beforeEntry?.item, afterEntry?.item, state);
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const maxLen = Math.max(before.length, after.length);
|
|
463
|
+
for (let index = 0; index < maxLen; index += 1) {
|
|
464
|
+
if (state.truncated) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
diffJsonValue(`${path}[${index}]`, before[index], after[index], state);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function findArrayMatchKey(before, after) {
|
|
472
|
+
if (before.length === 0 && after.length === 0) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
for (const key of ARRAY_MATCH_KEYS) {
|
|
476
|
+
if (arrayHasUniqueKey(before, key) && arrayHasUniqueKey(after, key)) {
|
|
477
|
+
return key;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function arrayHasUniqueKey(array, key) {
|
|
484
|
+
if (array.length === 0) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
for (const item of array) {
|
|
489
|
+
if (!isPlainObject(item)) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
const value = item[key];
|
|
493
|
+
if (value === undefined || (typeof value !== "string" && typeof value !== "number")) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
if (seen.has(value)) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
seen.add(value);
|
|
500
|
+
}
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function appendJsonPath(base, key) {
|
|
505
|
+
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) {
|
|
506
|
+
return `${base}.${key}`;
|
|
507
|
+
}
|
|
508
|
+
return `${base}[${JSON.stringify(key)}]`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isPlainObject(value) {
|
|
512
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function deepEqual(a, b) {
|
|
516
|
+
if (a === b) {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
if (typeof a !== typeof b) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
if (a && b && typeof a === "object") {
|
|
523
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- CSV diff (header-aware, row/cell keyed) ------------------------------
|
|
529
|
+
|
|
530
|
+
function buildCsvKindDiff(beforeStr, afterStr, cap) {
|
|
531
|
+
const beforeParsed = parseCsv(beforeStr);
|
|
532
|
+
const afterParsed = parseCsv(afterStr);
|
|
533
|
+
if (!beforeParsed.ok || !afterParsed.ok) {
|
|
534
|
+
// Fail closed the same way the CSV renderer does: an unterminated
|
|
535
|
+
// quoted field is not silently treated as a bogus single-cell row, it
|
|
536
|
+
// falls through to the line diff so the same artifact doesn't render
|
|
537
|
+
// as an error but diff as data.
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
const beforeRows = beforeParsed.rows;
|
|
541
|
+
const afterRows = afterParsed.rows;
|
|
542
|
+
const state = createDiffState(cap);
|
|
543
|
+
|
|
544
|
+
const beforeHeader = beforeRows[0] || [];
|
|
545
|
+
const afterHeader = afterRows[0] || [];
|
|
546
|
+
const headerWidth = Math.max(beforeHeader.length, afterHeader.length);
|
|
547
|
+
for (let col = 0; col < headerWidth && !state.truncated; col += 1) {
|
|
548
|
+
const b = beforeHeader[col];
|
|
549
|
+
const a = afterHeader[col];
|
|
550
|
+
if (b !== a) {
|
|
551
|
+
pushEntry(state, { op: "changed", row: "header", column: col, before: b, after: a });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const beforeData = beforeRows.slice(1);
|
|
556
|
+
const afterData = afterRows.slice(1);
|
|
557
|
+
const keyUnique = beforeData.length > 0 && afterData.length > 0 &&
|
|
558
|
+
hasUniqueFirstColumn(beforeData) && hasUniqueFirstColumn(afterData);
|
|
559
|
+
|
|
560
|
+
if (keyUnique) {
|
|
561
|
+
diffCsvRowsByKey(beforeData, afterData, state);
|
|
562
|
+
} else {
|
|
563
|
+
diffCsvRowsByPosition(beforeData, afterData, state);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return { kind: "csv", entries: state.entries, truncated: state.truncated, summary: { ...state.counters } };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function hasUniqueFirstColumn(rows) {
|
|
570
|
+
const seen = new Set();
|
|
571
|
+
for (const row of rows) {
|
|
572
|
+
const key = row[0];
|
|
573
|
+
if (seen.has(key)) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
seen.add(key);
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function diffCsvRowsByKey(beforeData, afterData, state) {
|
|
582
|
+
const beforeByKey = new Map(beforeData.map((row) => [row[0], row]));
|
|
583
|
+
const afterByKey = new Map(afterData.map((row) => [row[0], row]));
|
|
584
|
+
const order = [];
|
|
585
|
+
const seen = new Set();
|
|
586
|
+
for (const row of beforeData) {
|
|
587
|
+
if (!seen.has(row[0])) {
|
|
588
|
+
seen.add(row[0]);
|
|
589
|
+
order.push(row[0]);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
for (const row of afterData) {
|
|
593
|
+
if (!seen.has(row[0])) {
|
|
594
|
+
seen.add(row[0]);
|
|
595
|
+
order.push(row[0]);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
for (const key of order) {
|
|
599
|
+
if (state.truncated) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
diffCsvRowPair(key, beforeByKey.get(key), afterByKey.get(key), state);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function diffCsvRowsByPosition(beforeData, afterData, state) {
|
|
607
|
+
const maxLen = Math.max(beforeData.length, afterData.length);
|
|
608
|
+
for (let index = 0; index < maxLen; index += 1) {
|
|
609
|
+
if (state.truncated) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
diffCsvRowPair(index, beforeData[index], afterData[index], state);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function diffCsvRowPair(row, beforeRow, afterRow, state) {
|
|
617
|
+
if (beforeRow && !afterRow) {
|
|
618
|
+
pushEntry(state, { op: "removed", row, cells: beforeRow });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (!beforeRow && afterRow) {
|
|
622
|
+
pushEntry(state, { op: "added", row, cells: afterRow });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const width = Math.max(beforeRow.length, afterRow.length);
|
|
626
|
+
for (let col = 0; col < width; col += 1) {
|
|
627
|
+
if (state.truncated) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (beforeRow[col] !== afterRow[col]) {
|
|
631
|
+
pushEntry(state, { op: "changed", row, column: col, before: beforeRow[col], after: afterRow[col] });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// --- Bundle diff (per-file diff using each file's own format) ------------
|
|
637
|
+
|
|
638
|
+
function buildBundleKindDiff(beforeStr, afterStr, cap) {
|
|
639
|
+
let beforeBundle;
|
|
640
|
+
let afterBundle;
|
|
641
|
+
try {
|
|
642
|
+
beforeBundle = JSON.parse(beforeStr);
|
|
643
|
+
afterBundle = JSON.parse(afterStr);
|
|
644
|
+
} catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
if (!isPlainObject(beforeBundle) || !isPlainObject(afterBundle)) {
|
|
648
|
+
// A version that predates the bundle shape (or any other non-object
|
|
649
|
+
// JSON) has no `.files` to diff structurally. Fall through to the line
|
|
650
|
+
// diff instead of throwing on the property access below.
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const beforeFiles = Array.isArray(beforeBundle.files) ? beforeBundle.files : [];
|
|
655
|
+
const afterFiles = Array.isArray(afterBundle.files) ? afterBundle.files : [];
|
|
656
|
+
const beforeByPath = new Map(beforeFiles.map((file) => [file.path, file]));
|
|
657
|
+
const afterByPath = new Map(afterFiles.map((file) => [file.path, file]));
|
|
658
|
+
const order = [];
|
|
659
|
+
const seen = new Set();
|
|
660
|
+
for (const file of beforeFiles) {
|
|
661
|
+
if (!seen.has(file.path)) {
|
|
662
|
+
seen.add(file.path);
|
|
663
|
+
order.push(file.path);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
for (const file of afterFiles) {
|
|
667
|
+
if (!seen.has(file.path)) {
|
|
668
|
+
seen.add(file.path);
|
|
669
|
+
order.push(file.path);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const state = createDiffState(cap);
|
|
674
|
+
for (const filePath of order) {
|
|
675
|
+
if (state.truncated) {
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
const beforeFile = beforeByPath.get(filePath);
|
|
679
|
+
const afterFile = afterByPath.get(filePath);
|
|
680
|
+
|
|
681
|
+
if (beforeFile && !afterFile) {
|
|
682
|
+
pushEntry(state, { op: "removed", file: filePath });
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (!beforeFile && afterFile) {
|
|
686
|
+
pushEntry(state, { op: "added", file: filePath });
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (beforeFile.content === afterFile.content) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const fileFormat = detectBundleFileFormat(afterFile) || detectBundleFileFormat(beforeFile);
|
|
694
|
+
const remainingCap = state.cap - state.entries.length;
|
|
695
|
+
const sub = createStructuredDiff(beforeFile.content, afterFile.content, {
|
|
696
|
+
format: fileFormat,
|
|
697
|
+
maxEntries: remainingCap
|
|
698
|
+
});
|
|
699
|
+
for (const entry of sub.entries) {
|
|
700
|
+
if (state.truncated) {
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
if (entry.op === "same") {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const merged = { ...entry, file: filePath, kind: sub.kind };
|
|
707
|
+
const ok = pushEntry(state, merged);
|
|
708
|
+
if (!ok) {
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (sub.truncated) {
|
|
713
|
+
state.truncated = true;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return { kind: "bundle", entries: state.entries, truncated: state.truncated, summary: { ...state.counters } };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function detectBundleFileFormat(file) {
|
|
721
|
+
if (!file) {
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
return detectFormat({ content: file.content || "", contentType: file.contentType || "", fileName: file.path || "" });
|
|
725
|
+
}
|