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