ssrwire 0.2.0 → 0.4.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/CONTRIBUTING.md +3 -1
  3. package/PUBLISHING.md +15 -15
  4. package/README.md +179 -29
  5. package/dist/analyze.d.ts.map +1 -1
  6. package/dist/analyze.js +162 -6
  7. package/dist/analyze.js.map +1 -1
  8. package/dist/audit-report.d.ts +8 -0
  9. package/dist/audit-report.d.ts.map +1 -0
  10. package/dist/audit-report.js +243 -0
  11. package/dist/audit-report.js.map +1 -0
  12. package/dist/audit.d.ts.map +1 -1
  13. package/dist/audit.js +2 -0
  14. package/dist/audit.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +77 -10
  17. package/dist/cli.js.map +1 -1
  18. package/dist/compare.d.ts +6 -0
  19. package/dist/compare.d.ts.map +1 -0
  20. package/dist/compare.js +720 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/comparison-reporters.d.ts +9 -0
  23. package/dist/comparison-reporters.d.ts.map +1 -0
  24. package/dist/comparison-reporters.js +223 -0
  25. package/dist/comparison-reporters.js.map +1 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +20 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/http-probe.d.ts.map +1 -1
  30. package/dist/http-probe.js +4 -0
  31. package/dist/http-probe.js.map +1 -1
  32. package/dist/index.d.ts +4 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redact.d.ts.map +1 -1
  37. package/dist/redact.js +6 -0
  38. package/dist/redact.js.map +1 -1
  39. package/dist/reporters.d.ts.map +1 -1
  40. package/dist/reporters.js +27 -0
  41. package/dist/reporters.js.map +1 -1
  42. package/dist/social.d.ts +14 -0
  43. package/dist/social.d.ts.map +1 -0
  44. package/dist/social.js +88 -0
  45. package/dist/social.js.map +1 -0
  46. package/dist/stability.d.ts.map +1 -1
  47. package/dist/stability.js +84 -4
  48. package/dist/stability.js.map +1 -1
  49. package/dist/stream-parser.d.ts.map +1 -1
  50. package/dist/stream-parser.js +21 -1
  51. package/dist/stream-parser.js.map +1 -1
  52. package/dist/types.d.ts +92 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/examples/github-actions.yml +2 -2
  55. package/examples/ssrwire.config.yml +8 -2
  56. package/package.json +5 -2
  57. package/src/analyze.ts +208 -7
  58. package/src/audit-report.ts +269 -0
  59. package/src/audit.ts +2 -0
  60. package/src/cli.ts +106 -12
  61. package/src/compare.ts +949 -0
  62. package/src/comparison-reporters.ts +276 -0
  63. package/src/config.ts +19 -1
  64. package/src/http-probe.ts +4 -0
  65. package/src/index.ts +29 -0
  66. package/src/redact.ts +6 -0
  67. package/src/reporters.ts +46 -0
  68. package/src/social.ts +116 -0
  69. package/src/stability.ts +123 -4
  70. package/src/stream-parser.ts +31 -1
  71. package/src/types.ts +117 -0
@@ -0,0 +1,720 @@
1
+ import { AUDIT_SCHEMA_VERSION } from "./audit-report.js";
2
+ import { effectiveTwitterCardSignal, firstSocialSignal, OPEN_GRAPH_REQUIRED_PROPERTIES, TWITTER_CARD_REQUIRED_FIELDS, } from "./social.js";
3
+ import { calculateTimingStats, criticalSignalsArrivalMs } from "./stability.js";
4
+ import { VERSION } from "./version.js";
5
+ const DEFAULT_TIMING_REGRESSION_MS = 250;
6
+ const DEFAULT_TIMING_REGRESSION_PERCENT = 25;
7
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
8
+ const SEVERITY_RANK = { info: 0, warning: 1, error: 2 };
9
+ const SOCIAL_PROPERTIES = [
10
+ "og:title",
11
+ "og:type",
12
+ "og:url",
13
+ "og:image",
14
+ "og:description",
15
+ "twitter:card",
16
+ "twitter:title",
17
+ "twitter:description",
18
+ "twitter:image",
19
+ ];
20
+ function socialSignals(probe, property) {
21
+ return (probe.signals.socialMetadata ?? []).filter((signal) => signal.property === property);
22
+ }
23
+ const METADATA_FIELDS = [
24
+ {
25
+ key: "title",
26
+ label: "title",
27
+ signals: (probe) => probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []),
28
+ },
29
+ { key: "description", label: "description", signals: (probe) => probe.signals.descriptions },
30
+ { key: "canonical", label: "canonical", signals: (probe) => probe.signals.canonicals },
31
+ { key: "robots", label: "robots", signals: (probe) => probe.signals.robots },
32
+ { key: "h1", label: "H1", signals: (probe) => probe.signals.h1s },
33
+ {
34
+ key: "main-text",
35
+ label: "main text",
36
+ signals: (probe) => probe.signals.firstMainText === undefined ? [] : [probe.signals.firstMainText],
37
+ },
38
+ ...SOCIAL_PROPERTIES.map((property) => ({
39
+ key: property,
40
+ label: property,
41
+ signals: (probe) => socialSignals(probe, property),
42
+ })),
43
+ ];
44
+ export class ComparisonError extends Error {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = "ComparisonError";
48
+ }
49
+ }
50
+ function normalizeText(value) {
51
+ return value.trim().replace(/\s+/g, " ");
52
+ }
53
+ function rounded(value) {
54
+ return Math.round(value * 100) / 100;
55
+ }
56
+ function median(values) {
57
+ return calculateTimingStats(values)?.medianMs;
58
+ }
59
+ function targetKey(target) {
60
+ return target.id === undefined ? `url:${target.url}` : `id:${target.id}`;
61
+ }
62
+ function displayKey(target) {
63
+ return target.id ?? target.url;
64
+ }
65
+ function targetMap(report, label) {
66
+ const results = new Map();
67
+ for (const result of report.results) {
68
+ const key = targetKey(result.target);
69
+ if (results.has(key)) {
70
+ throw new ComparisonError(`${label} contains a duplicate target identity.`);
71
+ }
72
+ results.set(key, result);
73
+ }
74
+ return results;
75
+ }
76
+ function threshold(value, fallback, label) {
77
+ const resolved = value ?? fallback;
78
+ if (!Number.isFinite(resolved) || resolved < 0) {
79
+ throw new ComparisonError(`${label} must be a finite non-negative number.`);
80
+ }
81
+ return resolved;
82
+ }
83
+ function kindOrder(kind) {
84
+ if (kind === "regression")
85
+ return 0;
86
+ if (kind === "fixed")
87
+ return 1;
88
+ return 2;
89
+ }
90
+ function sortChanges(changes) {
91
+ return [...changes].sort((left, right) => kindOrder(left.kind) - kindOrder(right.kind) ||
92
+ (left.agent ?? "").localeCompare(right.agent ?? "") ||
93
+ (left.field ?? "").localeCompare(right.field ?? "") ||
94
+ left.code.localeCompare(right.code));
95
+ }
96
+ function findingKey(finding) {
97
+ return `${finding.code}\u0000${finding.agent ?? ""}`;
98
+ }
99
+ function findingMap(findings) {
100
+ const mapped = new Map();
101
+ for (const finding of findings) {
102
+ const key = findingKey(finding);
103
+ const existing = mapped.get(key);
104
+ if (existing === undefined ||
105
+ SEVERITY_RANK[finding.severity] > SEVERITY_RANK[existing.severity]) {
106
+ mapped.set(key, finding);
107
+ }
108
+ }
109
+ return mapped;
110
+ }
111
+ function stableEvidence(finding) {
112
+ return JSON.stringify(Object.fromEntries(Object.entries(finding.evidence ?? {}).sort(([left], [right]) => left.localeCompare(right))));
113
+ }
114
+ function compareFindings(baseline, candidate, ignoredCodes = new Set()) {
115
+ const baselineMap = findingMap(baseline.filter((finding) => !ignoredCodes.has(finding.code)));
116
+ const candidateMap = findingMap(candidate.filter((finding) => !ignoredCodes.has(finding.code)));
117
+ const keys = new Set([...baselineMap.keys(), ...candidateMap.keys()]);
118
+ const changes = [];
119
+ for (const key of keys) {
120
+ const before = baselineMap.get(key);
121
+ const after = candidateMap.get(key);
122
+ if (before === undefined && after !== undefined) {
123
+ changes.push({
124
+ kind: after.severity === "info" ? "changed" : "regression",
125
+ scope: "finding",
126
+ code: after.code,
127
+ message: `New ${after.severity} finding: ${after.message}`,
128
+ ...(after.agent === undefined ? {} : { agent: after.agent }),
129
+ candidate: after.severity,
130
+ });
131
+ continue;
132
+ }
133
+ if (before !== undefined && after === undefined) {
134
+ changes.push({
135
+ kind: "fixed",
136
+ scope: "finding",
137
+ code: before.code,
138
+ message: `Resolved ${before.severity} finding: ${before.message}`,
139
+ ...(before.agent === undefined ? {} : { agent: before.agent }),
140
+ baseline: before.severity,
141
+ });
142
+ continue;
143
+ }
144
+ if (before === undefined || after === undefined) {
145
+ continue;
146
+ }
147
+ if (before.severity === after.severity) {
148
+ if (stableEvidence(before) !== stableEvidence(after)) {
149
+ changes.push({
150
+ kind: "changed",
151
+ scope: "finding",
152
+ code: "finding-evidence-changed",
153
+ message: `${after.code} evidence changed while severity remained ${after.severity}.`,
154
+ ...(after.agent === undefined ? {} : { agent: after.agent }),
155
+ field: after.code,
156
+ baseline: stableEvidence(before),
157
+ candidate: stableEvidence(after),
158
+ });
159
+ }
160
+ continue;
161
+ }
162
+ const worsened = SEVERITY_RANK[after.severity] > SEVERITY_RANK[before.severity];
163
+ changes.push({
164
+ kind: worsened ? "regression" : "fixed",
165
+ scope: "finding",
166
+ code: after.code,
167
+ message: `${after.code} severity ${worsened ? "increased" : "decreased"} from ${before.severity} to ${after.severity}.`,
168
+ ...(after.agent === undefined ? {} : { agent: after.agent }),
169
+ baseline: before.severity,
170
+ candidate: after.severity,
171
+ });
172
+ }
173
+ return changes;
174
+ }
175
+ function agentGroups(result) {
176
+ const groups = new Map();
177
+ for (const probe of result.probes) {
178
+ const probes = groups.get(probe.agent.key) ?? [];
179
+ probes.push(probe);
180
+ groups.set(probe.agent.key, probes);
181
+ }
182
+ return groups;
183
+ }
184
+ function uniqueStrings(values) {
185
+ return [...new Set(values)].sort();
186
+ }
187
+ function valuesEqual(left, right) {
188
+ return left.length === right.length && left.every((value, index) => value === right[index]);
189
+ }
190
+ function displayValues(values) {
191
+ if (values.length === 0)
192
+ return "<missing>";
193
+ const visible = values
194
+ .slice(0, 3)
195
+ .map((value) => (value.length <= 160 ? value : `${value.slice(0, 159)}…`));
196
+ return `${visible.join(" | ")}${values.length > visible.length ? ` | +${values.length - visible.length} more` : ""}`;
197
+ }
198
+ function normalizeUrlForOrigin(value, originUrl) {
199
+ try {
200
+ const url = new URL(value, originUrl);
201
+ const origin = new URL(originUrl).origin;
202
+ return url.origin === origin ? `${url.pathname}${url.search}` : url.href;
203
+ }
204
+ catch {
205
+ return normalizeText(value);
206
+ }
207
+ }
208
+ function compareResponses(baselineTarget, candidateTarget, agent, baseline, candidate) {
209
+ const changes = [];
210
+ const beforeCompletions = uniqueStrings(baseline.map((probe) => probe.completion));
211
+ const afterCompletions = uniqueStrings(candidate.map((probe) => probe.completion));
212
+ if (!valuesEqual(beforeCompletions, afterCompletions)) {
213
+ const beforeComplete = beforeCompletions.length === 1 && beforeCompletions[0] === "complete";
214
+ const afterComplete = afterCompletions.length === 1 && afterCompletions[0] === "complete";
215
+ const kind = beforeComplete
216
+ ? "regression"
217
+ : afterComplete
218
+ ? "fixed"
219
+ : "changed";
220
+ changes.push({
221
+ kind,
222
+ scope: "response",
223
+ code: "probe-completion-changed",
224
+ message: `Probe completion changed from ${beforeCompletions.join(", ")} to ${afterCompletions.join(", ")}.`,
225
+ agent,
226
+ field: "completion",
227
+ baseline: beforeCompletions.join(", "),
228
+ candidate: afterCompletions.join(", "),
229
+ });
230
+ }
231
+ const statuses = (probes) => uniqueStrings(probes.map((probe) => String(probe.status ?? "missing")));
232
+ const beforeStatuses = statuses(baseline);
233
+ const afterStatuses = statuses(candidate);
234
+ if (!valuesEqual(beforeStatuses, afterStatuses)) {
235
+ changes.push({
236
+ kind: "changed",
237
+ scope: "response",
238
+ code: "http-status-changed",
239
+ message: `HTTP status changed from ${beforeStatuses.join(", ")} to ${afterStatuses.join(", ")}.`,
240
+ agent,
241
+ field: "status",
242
+ baseline: beforeStatuses.join(", "),
243
+ candidate: afterStatuses.join(", "),
244
+ });
245
+ }
246
+ const finalUrls = (probes, target) => uniqueStrings(probes.map((probe) => normalizeUrlForOrigin(probe.finalUrl, target.url)));
247
+ const beforeFinalUrls = finalUrls(baseline, baselineTarget);
248
+ const afterFinalUrls = finalUrls(candidate, candidateTarget);
249
+ if (!valuesEqual(beforeFinalUrls, afterFinalUrls)) {
250
+ changes.push({
251
+ kind: "changed",
252
+ scope: "response",
253
+ code: "final-url-changed",
254
+ message: "Final response route changed.",
255
+ agent,
256
+ field: "final-url",
257
+ baseline: displayValues(beforeFinalUrls),
258
+ candidate: displayValues(afterFinalUrls),
259
+ });
260
+ }
261
+ const redirectChains = (probes, target) => uniqueStrings(probes.map((probe) => probe.redirects.length === 0
262
+ ? "<none>"
263
+ : probe.redirects
264
+ .map((redirect) => `${redirect.status} ${normalizeUrlForOrigin(redirect.location, target.url)}`)
265
+ .join(" → ")));
266
+ const beforeRedirects = redirectChains(baseline, baselineTarget);
267
+ const afterRedirects = redirectChains(candidate, candidateTarget);
268
+ if (!valuesEqual(beforeRedirects, afterRedirects)) {
269
+ changes.push({
270
+ kind: "changed",
271
+ scope: "response",
272
+ code: "redirect-chain-changed",
273
+ message: "Redirect chain changed.",
274
+ agent,
275
+ field: "redirects",
276
+ baseline: displayValues(beforeRedirects),
277
+ candidate: displayValues(afterRedirects),
278
+ });
279
+ }
280
+ return changes;
281
+ }
282
+ function metadataValues(field, probes) {
283
+ return uniqueStrings(probes
284
+ .filter((probe) => probe.completion === "complete")
285
+ .flatMap((probe) => field.signals(probe))
286
+ .map((signal) => normalizeText(signal.value))
287
+ .filter((value) => value.length > 0));
288
+ }
289
+ function metadataLocations(field, probes) {
290
+ return uniqueStrings(probes
291
+ .filter((probe) => probe.completion === "complete")
292
+ .flatMap((probe) => field.signals(probe))
293
+ .filter((signal) => normalizeText(signal.value).length > 0)
294
+ .map((signal) => signal.location));
295
+ }
296
+ function compareMetadata(agent, baseline, candidate) {
297
+ if (!baseline.some((probe) => probe.completion === "complete") ||
298
+ !candidate.some((probe) => probe.completion === "complete")) {
299
+ return [];
300
+ }
301
+ const changes = [];
302
+ for (const field of METADATA_FIELDS) {
303
+ const beforeValues = metadataValues(field, baseline);
304
+ const afterValues = metadataValues(field, candidate);
305
+ if (!valuesEqual(beforeValues, afterValues)) {
306
+ changes.push({
307
+ kind: "changed",
308
+ scope: "metadata",
309
+ code: "metadata-value-changed",
310
+ message: `${field.label} value changed.`,
311
+ agent,
312
+ field: field.key,
313
+ baseline: displayValues(beforeValues),
314
+ candidate: displayValues(afterValues),
315
+ });
316
+ }
317
+ const beforeLocations = metadataLocations(field, baseline);
318
+ const afterLocations = metadataLocations(field, candidate);
319
+ if (!valuesEqual(beforeLocations, afterLocations)) {
320
+ changes.push({
321
+ kind: "changed",
322
+ scope: "metadata",
323
+ code: "metadata-location-changed",
324
+ message: `${field.label} document location changed.`,
325
+ agent,
326
+ field: field.key,
327
+ baseline: displayValues(beforeLocations),
328
+ candidate: displayValues(afterLocations),
329
+ });
330
+ }
331
+ }
332
+ return changes;
333
+ }
334
+ function timingValues(metric, target, probes) {
335
+ if (metric === "headers") {
336
+ return probes.flatMap((probe) => probe.status !== undefined && !REDIRECT_STATUSES.has(probe.status)
337
+ ? [probe.timings.headersMs]
338
+ : []);
339
+ }
340
+ if (metric === "first-byte") {
341
+ return probes.flatMap((probe) => probe.timings.firstByteMs === undefined ? [] : [probe.timings.firstByteMs]);
342
+ }
343
+ if (metric === "complete") {
344
+ return probes.flatMap((probe) => probe.completion === "complete" && probe.timings.completeMs !== undefined
345
+ ? [probe.timings.completeMs]
346
+ : []);
347
+ }
348
+ return probes.flatMap((probe) => {
349
+ if (probe.completion !== "complete")
350
+ return [];
351
+ const value = criticalSignalsArrivalMs(target, probe);
352
+ return value === undefined ? [] : [value];
353
+ });
354
+ }
355
+ function crossesTimingThreshold(improvementMs, referenceMs, minimumMs, minimumPercent) {
356
+ const percent = referenceMs === 0
357
+ ? improvementMs > 0
358
+ ? Number.POSITIVE_INFINITY
359
+ : 0
360
+ : (improvementMs / referenceMs) * 100;
361
+ return improvementMs > minimumMs && percent > minimumPercent;
362
+ }
363
+ function compareTimings(baselineTarget, candidateTarget, agent, baseline, candidate, minimumMs, minimumPercent) {
364
+ const changes = [];
365
+ const labels = {
366
+ headers: "response headers",
367
+ "first-byte": "first byte",
368
+ "critical-signals": "required signals",
369
+ complete: "completion",
370
+ };
371
+ const sameCriticalPolicy = baselineTarget.expectations.requireTitle === candidateTarget.expectations.requireTitle &&
372
+ baselineTarget.expectations.requireDescription ===
373
+ candidateTarget.expectations.requireDescription &&
374
+ baselineTarget.expectations.requireCanonical ===
375
+ candidateTarget.expectations.requireCanonical &&
376
+ baselineTarget.expectations.requireH1 === candidateTarget.expectations.requireH1 &&
377
+ baselineTarget.expectations.requireMainText === candidateTarget.expectations.requireMainText &&
378
+ (baselineTarget.expectations.requireOpenGraph ?? false) ===
379
+ (candidateTarget.expectations.requireOpenGraph ?? false) &&
380
+ (baselineTarget.expectations.requireTwitterCard ?? false) ===
381
+ (candidateTarget.expectations.requireTwitterCard ?? false);
382
+ for (const metric of ["headers", "first-byte", "critical-signals", "complete"]) {
383
+ if (metric === "critical-signals" && !sameCriticalPolicy)
384
+ continue;
385
+ const before = median(timingValues(metric, baselineTarget, baseline));
386
+ const after = median(timingValues(metric, candidateTarget, candidate));
387
+ if (before === undefined || after === undefined || before === after)
388
+ continue;
389
+ const delta = after - before;
390
+ const slower = delta > 0;
391
+ if (!crossesTimingThreshold(Math.abs(delta), before, minimumMs, minimumPercent))
392
+ continue;
393
+ const percent = before === 0 ? Number.POSITIVE_INFINITY : (Math.abs(delta) / before) * 100;
394
+ changes.push({
395
+ kind: slower ? "regression" : "fixed",
396
+ scope: "timing",
397
+ code: slower ? "timing-regression" : "timing-improvement",
398
+ message: `${labels[metric]} median became ${rounded(Math.abs(delta))} ms (${Number.isFinite(percent) ? `${rounded(percent)}%` : "∞"}) ${slower ? "slower" : "faster"}.`,
399
+ agent,
400
+ field: metric,
401
+ baseline: rounded(before),
402
+ candidate: rounded(after),
403
+ });
404
+ }
405
+ return changes;
406
+ }
407
+ function changedExpectationFields(baseline, candidate) {
408
+ const fields = [
409
+ "statuses",
410
+ "finalUrl",
411
+ "requireTitle",
412
+ "requireDescription",
413
+ "requireCanonical",
414
+ "requireH1",
415
+ "requireMainText",
416
+ "requireOpenGraph",
417
+ "requireTwitterCard",
418
+ "maxFirstByteMs",
419
+ "maxCriticalMs",
420
+ ];
421
+ return fields.filter((field) => JSON.stringify(baseline[field] ?? null) !== JSON.stringify(candidate[field] ?? null));
422
+ }
423
+ function combinedLocation(signals) {
424
+ const locations = uniqueStrings(signals.map((signal) => signal.location));
425
+ if (locations.length === 0)
426
+ return undefined;
427
+ if (locations.length > 1)
428
+ return "mixed";
429
+ const location = locations[0];
430
+ return location === "head" || location === "body" || location === "document"
431
+ ? location
432
+ : undefined;
433
+ }
434
+ function signalObservation(signals) {
435
+ const usable = signals.filter((signal) => normalizeText(signal.value).length > 0);
436
+ const arrival = usable.length === 0 ? undefined : Math.max(...usable.map((signal) => signal.atMs));
437
+ if (arrival === undefined)
438
+ return undefined;
439
+ const location = combinedLocation(usable);
440
+ return {
441
+ atMs: arrival,
442
+ observedByByte: Math.max(...usable.map((signal) => signal.observedByByte)),
443
+ ...(location === undefined ? {} : { location }),
444
+ };
445
+ }
446
+ function firstNonEmptySignal(signals) {
447
+ const signal = signals.find((item) => normalizeText(item.value).length > 0);
448
+ return signal === undefined ? [] : [signal];
449
+ }
450
+ function twitterReadySignals(probe) {
451
+ return TWITTER_CARD_REQUIRED_FIELDS.flatMap((field) => {
452
+ const signal = effectiveTwitterCardSignal(probe.signals, field);
453
+ return signal === undefined ? [] : [signal];
454
+ });
455
+ }
456
+ const TIMELINE_DEFINITIONS = [
457
+ {
458
+ key: "headers",
459
+ label: "Headers",
460
+ observe: (_target, probe) => ({ atMs: probe.timings.headersMs }),
461
+ },
462
+ {
463
+ key: "first-byte",
464
+ label: "First byte",
465
+ observe: (_target, probe) => probe.timings.firstByteMs === undefined ? undefined : { atMs: probe.timings.firstByteMs },
466
+ },
467
+ {
468
+ key: "title",
469
+ label: "Title",
470
+ observe: (_target, probe) => signalObservation(probe.signals.title === undefined ? [] : [probe.signals.title]),
471
+ },
472
+ {
473
+ key: "description",
474
+ label: "Description",
475
+ observe: (_target, probe) => signalObservation(firstNonEmptySignal(probe.signals.descriptions)),
476
+ },
477
+ {
478
+ key: "canonical",
479
+ label: "Canonical",
480
+ observe: (_target, probe) => signalObservation(firstNonEmptySignal(probe.signals.canonicals)),
481
+ },
482
+ {
483
+ key: "open-graph",
484
+ label: "Open Graph ready",
485
+ observe: (_target, probe) => {
486
+ const signals = OPEN_GRAPH_REQUIRED_PROPERTIES.flatMap((property) => {
487
+ const signal = firstSocialSignal(probe.signals, property);
488
+ return signal === undefined ? [] : [signal];
489
+ });
490
+ return signals.length === OPEN_GRAPH_REQUIRED_PROPERTIES.length
491
+ ? signalObservation(signals)
492
+ : undefined;
493
+ },
494
+ },
495
+ {
496
+ key: "twitter-card",
497
+ label: "Twitter Card ready",
498
+ observe: (_target, probe) => {
499
+ const signals = twitterReadySignals(probe);
500
+ return signals.length === TWITTER_CARD_REQUIRED_FIELDS.length
501
+ ? signalObservation(signals)
502
+ : undefined;
503
+ },
504
+ },
505
+ {
506
+ key: "required",
507
+ label: "Required ready",
508
+ observe: (target, probe) => {
509
+ const atMs = criticalSignalsArrivalMs(target, probe);
510
+ return atMs === undefined ? undefined : { atMs };
511
+ },
512
+ },
513
+ {
514
+ key: "main",
515
+ label: "Main text",
516
+ observe: (_target, probe) => signalObservation(probe.signals.firstMainText === undefined ? [] : [probe.signals.firstMainText]),
517
+ },
518
+ {
519
+ key: "complete",
520
+ label: "Complete",
521
+ observe: (_target, probe) => probe.completion !== "complete" || probe.timings.completeMs === undefined
522
+ ? undefined
523
+ : { atMs: probe.timings.completeMs },
524
+ },
525
+ ];
526
+ function timelineSnapshot(target, probes) {
527
+ if (probes.length === 0)
528
+ return undefined;
529
+ const events = [];
530
+ for (const definition of TIMELINE_DEFINITIONS) {
531
+ const observations = probes.flatMap((probe) => {
532
+ const observation = definition.observe(target, probe);
533
+ return observation === undefined ? [] : [observation];
534
+ });
535
+ const medianMs = median(observations.map((observation) => observation.atMs));
536
+ if (medianMs === undefined)
537
+ continue;
538
+ const bytes = median(observations.flatMap((observation) => observation.observedByByte === undefined ? [] : [observation.observedByByte]));
539
+ const locations = uniqueStrings(observations.flatMap((observation) => observation.location === undefined ? [] : [observation.location]));
540
+ const location = locations.length === 1 ? locations[0] : locations.length > 1 ? "mixed" : undefined;
541
+ events.push({
542
+ key: definition.key,
543
+ label: definition.label,
544
+ medianMs: rounded(medianMs),
545
+ ...(location === "head" ||
546
+ location === "body" ||
547
+ location === "document" ||
548
+ location === "mixed"
549
+ ? { location }
550
+ : {}),
551
+ ...(bytes === undefined ? {} : { observedByByte: rounded(bytes) }),
552
+ });
553
+ }
554
+ return { samples: probes.length, events };
555
+ }
556
+ function comparisonTimelines(baseline, candidate) {
557
+ const before = baseline === undefined ? new Map() : agentGroups(baseline);
558
+ const after = candidate === undefined ? new Map() : agentGroups(candidate);
559
+ const agentKeys = [...new Set([...before.keys(), ...after.keys()])];
560
+ return agentKeys.map((agent) => {
561
+ const baselineProbes = before.get(agent) ?? [];
562
+ const candidateProbes = after.get(agent) ?? [];
563
+ const profile = candidateProbes[0]?.agent ?? baselineProbes[0]?.agent;
564
+ const baselineSnapshot = baseline === undefined ? undefined : timelineSnapshot(baseline.target, baselineProbes);
565
+ const candidateSnapshot = candidate === undefined ? undefined : timelineSnapshot(candidate.target, candidateProbes);
566
+ return {
567
+ agent,
568
+ label: profile?.label ?? agent,
569
+ ...(baselineSnapshot === undefined ? {} : { baseline: baselineSnapshot }),
570
+ ...(candidateSnapshot === undefined ? {} : { candidate: candidateSnapshot }),
571
+ };
572
+ });
573
+ }
574
+ function compareMatchedTarget(baseline, candidate, timingRegressionMs, timingRegressionPercent) {
575
+ const changes = [
576
+ ...compareFindings(baseline.findings, candidate.findings, new Set(["incomplete-probe"])),
577
+ ];
578
+ const policyFields = changedExpectationFields(baseline.target.expectations, candidate.target.expectations);
579
+ if (policyFields.length > 0) {
580
+ changes.push({
581
+ kind: "changed",
582
+ scope: "target",
583
+ code: "target-policy-changed",
584
+ message: `Target policy changed: ${policyFields.join(", ")}.`,
585
+ field: "expectations",
586
+ });
587
+ }
588
+ const beforeAgents = agentGroups(baseline);
589
+ const afterAgents = agentGroups(candidate);
590
+ const agents = new Set([...beforeAgents.keys(), ...afterAgents.keys()]);
591
+ for (const agent of agents) {
592
+ const before = beforeAgents.get(agent);
593
+ const after = afterAgents.get(agent);
594
+ if (before === undefined && after !== undefined) {
595
+ changes.push({
596
+ kind: "changed",
597
+ scope: "agent",
598
+ code: "agent-added",
599
+ message: `Agent ${agent} was added to the candidate report.`,
600
+ agent,
601
+ });
602
+ continue;
603
+ }
604
+ if (before !== undefined && after === undefined) {
605
+ changes.push({
606
+ kind: "changed",
607
+ scope: "agent",
608
+ code: "agent-removed",
609
+ message: `Agent ${agent} is absent from the candidate report.`,
610
+ agent,
611
+ });
612
+ continue;
613
+ }
614
+ if (before === undefined || after === undefined)
615
+ continue;
616
+ changes.push(...compareResponses(baseline.target, candidate.target, agent, before, after), ...compareMetadata(agent, before, after), ...compareTimings(baseline.target, candidate.target, agent, before, after, timingRegressionMs, timingRegressionPercent));
617
+ }
618
+ return sortChanges(changes);
619
+ }
620
+ export function compareAudits(baseline, candidate, options = {}) {
621
+ if (baseline.schemaVersion !== AUDIT_SCHEMA_VERSION ||
622
+ candidate.schemaVersion !== AUDIT_SCHEMA_VERSION) {
623
+ throw new ComparisonError(`Both audit reports must use schemaVersion ${AUDIT_SCHEMA_VERSION}.`);
624
+ }
625
+ const timingRegressionMs = threshold(options.timingRegressionMs, DEFAULT_TIMING_REGRESSION_MS, "timingRegressionMs");
626
+ const timingRegressionPercent = threshold(options.timingRegressionPercent, DEFAULT_TIMING_REGRESSION_PERCENT, "timingRegressionPercent");
627
+ const baselineMap = targetMap(baseline, "Baseline report");
628
+ const candidateMap = targetMap(candidate, "Candidate report");
629
+ const keys = [...baselineMap.keys(), ...candidateMap.keys()].filter((key, index, all) => all.indexOf(key) === index);
630
+ const results = [];
631
+ for (const key of keys) {
632
+ const before = baselineMap.get(key);
633
+ const after = candidateMap.get(key);
634
+ const target = after?.target ?? before?.target;
635
+ if (target === undefined)
636
+ continue;
637
+ if (before === undefined && after !== undefined) {
638
+ results.push({
639
+ key: displayKey(target),
640
+ ...(target.id === undefined ? {} : { id: target.id }),
641
+ status: "added",
642
+ candidateUrl: target.url,
643
+ changes: sortChanges([
644
+ {
645
+ kind: "changed",
646
+ scope: "target",
647
+ code: "target-added",
648
+ message: "Target exists only in the candidate report.",
649
+ },
650
+ ...compareFindings([], after.findings),
651
+ ]),
652
+ timelines: comparisonTimelines(undefined, after),
653
+ });
654
+ continue;
655
+ }
656
+ if (before !== undefined && after === undefined) {
657
+ results.push({
658
+ key: displayKey(target),
659
+ ...(target.id === undefined ? {} : { id: target.id }),
660
+ status: "removed",
661
+ baselineUrl: target.url,
662
+ changes: [
663
+ {
664
+ kind: "changed",
665
+ scope: "target",
666
+ code: "target-removed",
667
+ message: "Target exists only in the baseline report.",
668
+ },
669
+ ],
670
+ timelines: comparisonTimelines(before, undefined),
671
+ });
672
+ continue;
673
+ }
674
+ if (before === undefined || after === undefined)
675
+ continue;
676
+ results.push({
677
+ key: displayKey(after.target),
678
+ ...(after.target.id === undefined ? {} : { id: after.target.id }),
679
+ status: "matched",
680
+ baselineUrl: before.target.url,
681
+ candidateUrl: after.target.url,
682
+ changes: compareMatchedTarget(before, after, timingRegressionMs, timingRegressionPercent),
683
+ timelines: comparisonTimelines(before, after),
684
+ });
685
+ }
686
+ const allChanges = results.flatMap((result) => result.changes);
687
+ return {
688
+ schemaVersion: AUDIT_SCHEMA_VERSION,
689
+ kind: "comparison",
690
+ version: VERSION,
691
+ generatedAt: new Date().toISOString(),
692
+ baseline: {
693
+ label: options.baselineLabel ?? "Baseline",
694
+ version: baseline.version,
695
+ schemaVersion: baseline.schemaVersion,
696
+ generatedAt: baseline.generatedAt,
697
+ repeat: baseline.repeat ?? 1,
698
+ },
699
+ candidate: {
700
+ label: options.candidateLabel ?? "Candidate",
701
+ version: candidate.version,
702
+ schemaVersion: candidate.schemaVersion,
703
+ generatedAt: candidate.generatedAt,
704
+ repeat: candidate.repeat ?? 1,
705
+ },
706
+ thresholds: { timingRegressionMs, timingRegressionPercent },
707
+ results,
708
+ summary: {
709
+ targets: results.length,
710
+ matchedTargets: results.filter((result) => result.status === "matched").length,
711
+ addedTargets: results.filter((result) => result.status === "added").length,
712
+ removedTargets: results.filter((result) => result.status === "removed").length,
713
+ unchangedTargets: results.filter((result) => result.status === "matched" && result.changes.length === 0).length,
714
+ regressions: allChanges.filter((change) => change.kind === "regression").length,
715
+ fixed: allChanges.filter((change) => change.kind === "fixed").length,
716
+ changed: allChanges.filter((change) => change.kind === "changed").length,
717
+ },
718
+ };
719
+ }
720
+ //# sourceMappingURL=compare.js.map