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
@@ -0,0 +1,276 @@
1
+ import type {
2
+ AuditComparison,
3
+ ComparisonChange,
4
+ ComparisonKind,
5
+ ComparisonReportFormat,
6
+ ComparisonTimelineEvent,
7
+ ComparisonTimelineSnapshot,
8
+ TargetComparison,
9
+ } from "./types.js";
10
+
11
+ export interface ComparisonReporterOptions {
12
+ readonly color?: boolean;
13
+ }
14
+
15
+ const ANSI = {
16
+ red: "\u001b[31m",
17
+ green: "\u001b[32m",
18
+ yellow: "\u001b[33m",
19
+ blue: "\u001b[36m",
20
+ bold: "\u001b[1m",
21
+ dim: "\u001b[2m",
22
+ reset: "\u001b[0m",
23
+ } as const;
24
+
25
+ const EVENT_SHORT_LABELS: Readonly<Record<string, string>> = {
26
+ headers: "H",
27
+ "first-byte": "B",
28
+ title: "T",
29
+ description: "D",
30
+ canonical: "C",
31
+ "open-graph": "OG",
32
+ "twitter-card": "X",
33
+ required: "R",
34
+ main: "M",
35
+ complete: "✓",
36
+ };
37
+
38
+ function paint(value: string, code: string, enabled: boolean): string {
39
+ return enabled ? `${code}${value}${ANSI.reset}` : value;
40
+ }
41
+
42
+ function terminalSafe(value: string): string {
43
+ let safe = "";
44
+ for (const character of value) {
45
+ const codePoint = character.codePointAt(0) ?? 0;
46
+ safe +=
47
+ codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
48
+ ? `\\u${codePoint.toString(16).toUpperCase().padStart(4, "0")}`
49
+ : character;
50
+ }
51
+ return safe;
52
+ }
53
+
54
+ function stableValue(value: unknown): unknown {
55
+ if (Array.isArray(value)) return value.map(stableValue);
56
+ if (value !== null && typeof value === "object") {
57
+ return Object.fromEntries(
58
+ Object.entries(value)
59
+ .sort(([left], [right]) => left.localeCompare(right))
60
+ .map(([key, entry]) => [key, stableValue(entry)]),
61
+ );
62
+ }
63
+ return value;
64
+ }
65
+
66
+ function stableJson(value: unknown): string {
67
+ return `${JSON.stringify(stableValue(value), null, 2)}\n`;
68
+ }
69
+
70
+ function kindLabel(kind: ComparisonKind): string {
71
+ if (kind === "regression") return "REGRESS";
72
+ if (kind === "fixed") return "FIXED";
73
+ return "CHANGED";
74
+ }
75
+
76
+ function kindColor(kind: ComparisonKind): string {
77
+ if (kind === "regression") return ANSI.red;
78
+ if (kind === "fixed") return ANSI.green;
79
+ return ANSI.blue;
80
+ }
81
+
82
+ function formatChange(change: ComparisonChange, color: boolean): string {
83
+ const label = paint(kindLabel(change.kind).padEnd(7), kindColor(change.kind), color);
84
+ const qualifiers = [change.agent, change.field].filter(
85
+ (value): value is string => value !== undefined,
86
+ );
87
+ const context = qualifiers.length === 0 ? "" : ` [${qualifiers.map(terminalSafe).join("/")}]`;
88
+ const values =
89
+ change.baseline === undefined && change.candidate === undefined
90
+ ? ""
91
+ : ` (${terminalSafe(String(change.baseline ?? "—"))} → ${terminalSafe(String(change.candidate ?? "—"))})`;
92
+ return ` ${label} ${terminalSafe(change.code)}${context}: ${terminalSafe(change.message)}${values}`;
93
+ }
94
+
95
+ export function renderComparisonTerminal(
96
+ comparison: AuditComparison,
97
+ options: ComparisonReporterOptions = {},
98
+ ): string {
99
+ const color = options.color ?? Boolean(process.stdout.isTTY);
100
+ const lines = [
101
+ paint(`SSRWire ${comparison.version} comparison`, ANSI.bold, color),
102
+ `${paint("Baseline", ANSI.dim, color)}: ${terminalSafe(comparison.baseline.label)} · SSRWire ${terminalSafe(comparison.baseline.version)} · ${terminalSafe(comparison.baseline.generatedAt)}`,
103
+ `${paint("Candidate", ANSI.dim, color)}: ${terminalSafe(comparison.candidate.label)} · SSRWire ${terminalSafe(comparison.candidate.version)} · ${terminalSafe(comparison.candidate.generatedAt)}`,
104
+ paint(
105
+ `Timing regression requires >${comparison.thresholds.timingRegressionMs} ms and >${comparison.thresholds.timingRegressionPercent}% median increase`,
106
+ ANSI.dim,
107
+ color,
108
+ ),
109
+ ];
110
+
111
+ for (const result of comparison.results) {
112
+ lines.push("", paint(terminalSafe(result.key), ANSI.bold, color));
113
+ if (result.baselineUrl !== undefined) {
114
+ lines.push(` Baseline: ${terminalSafe(result.baselineUrl)}`);
115
+ }
116
+ if (result.candidateUrl !== undefined) {
117
+ lines.push(` Candidate: ${terminalSafe(result.candidateUrl)}`);
118
+ }
119
+ if (result.changes.length === 0) {
120
+ lines.push(paint(" No differences", ANSI.dim, color));
121
+ } else {
122
+ lines.push(...result.changes.map((change) => formatChange(change, color)));
123
+ }
124
+ }
125
+
126
+ const { summary } = comparison;
127
+ const summaryText =
128
+ `Summary: ${summary.matchedTargets} matched, ${summary.addedTargets} added, ` +
129
+ `${summary.removedTargets} removed, ${summary.unchangedTargets} unchanged; ` +
130
+ `${summary.regressions} regression(s), ${summary.fixed} fixed, ${summary.changed} changed`;
131
+ const summaryColor = summary.regressions > 0 ? ANSI.red : ANSI.green;
132
+ lines.push("", paint(summaryText, summaryColor, color));
133
+ return `${lines.join("\n")}\n`;
134
+ }
135
+
136
+ export function renderComparisonJson(comparison: AuditComparison): string {
137
+ return stableJson(comparison);
138
+ }
139
+
140
+ function escapeHtml(value: string): string {
141
+ return value
142
+ .replaceAll("&", "&amp;")
143
+ .replaceAll("<", "&lt;")
144
+ .replaceAll(">", "&gt;")
145
+ .replaceAll('"', "&quot;")
146
+ .replaceAll("'", "&#39;");
147
+ }
148
+
149
+ function formatMs(value: number): string {
150
+ return `${Math.round(value)} ms`;
151
+ }
152
+
153
+ function maxTimelineMs(result: TargetComparison): number {
154
+ return Math.max(
155
+ 1,
156
+ ...result.timelines.flatMap((lane) => [
157
+ ...(lane.baseline?.events.map((event) => event.medianMs) ?? []),
158
+ ...(lane.candidate?.events.map((event) => event.medianMs) ?? []),
159
+ ]),
160
+ );
161
+ }
162
+
163
+ function eventTitle(event: ComparisonTimelineEvent): string {
164
+ const details = [event.label, formatMs(event.medianMs)];
165
+ if (event.location !== undefined) details.push(event.location);
166
+ if (event.observedByByte !== undefined) {
167
+ details.push(`by byte ${Math.round(event.observedByByte)}`);
168
+ }
169
+ return details.join(" · ");
170
+ }
171
+
172
+ function renderTimelineSnapshot(
173
+ side: string,
174
+ snapshot: ComparisonTimelineSnapshot | undefined,
175
+ maxMs: number,
176
+ ): string {
177
+ if (snapshot === undefined) {
178
+ return `<div class="timeline-row"><div class="side">${escapeHtml(side)}</div><div class="missing">not present</div></div>`;
179
+ }
180
+ const events = snapshot.events
181
+ .map((event, index) => {
182
+ const position = Math.max(0, Math.min(100, (event.medianMs / maxMs) * 100));
183
+ const short = EVENT_SHORT_LABELS[event.key] ?? event.key.slice(0, 2).toUpperCase();
184
+ const title = escapeHtml(eventTitle(event));
185
+ return `<span class="event" style="--position:${position.toFixed(3)}%;--level:${index % 2}" title="${title}" aria-label="${title}" tabindex="0"><span>${escapeHtml(short)}</span></span>`;
186
+ })
187
+ .join("");
188
+ return `<div class="timeline-row"><div class="side">${escapeHtml(side)} <small>n=${snapshot.samples}</small></div><div class="track">${events}</div></div>`;
189
+ }
190
+
191
+ function renderTimelines(result: TargetComparison): string {
192
+ if (result.timelines.length === 0) return "";
193
+ const maxMs = maxTimelineMs(result);
194
+ const lanes = result.timelines
195
+ .map(
196
+ (lane) => `<section class="agent-lane">
197
+ <h4>${escapeHtml(lane.label)} <code>${escapeHtml(lane.agent)}</code></h4>
198
+ ${renderTimelineSnapshot("Baseline", lane.baseline, maxMs)}
199
+ ${renderTimelineSnapshot("Candidate", lane.candidate, maxMs)}
200
+ </section>`,
201
+ )
202
+ .join("");
203
+ return `<details class="waterfall" open><summary>Wire waterfall <span>0–${escapeHtml(formatMs(maxMs))}</span></summary>${lanes}</details>`;
204
+ }
205
+
206
+ function renderChange(change: ComparisonChange): string {
207
+ const context = [change.agent, change.field]
208
+ .filter((value): value is string => value !== undefined)
209
+ .map((value) => `<code>${escapeHtml(value)}</code>`)
210
+ .join(" ");
211
+ const values =
212
+ change.baseline === undefined && change.candidate === undefined
213
+ ? ""
214
+ : `<dl><div><dt>Baseline</dt><dd>${escapeHtml(String(change.baseline ?? "—"))}</dd></div><div><dt>Candidate</dt><dd>${escapeHtml(String(change.candidate ?? "—"))}</dd></div></dl>`;
215
+ return `<article class="change ${change.kind}">
216
+ <div class="change-heading"><span>${escapeHtml(kindLabel(change.kind))}</span><strong>${escapeHtml(change.code)}</strong>${context}</div>
217
+ <p>${escapeHtml(change.message)}</p>${values}
218
+ </article>`;
219
+ }
220
+
221
+ function renderTarget(result: TargetComparison): string {
222
+ const urls = [
223
+ result.baselineUrl === undefined
224
+ ? ""
225
+ : `<div><span>Baseline</span>${escapeHtml(result.baselineUrl)}</div>`,
226
+ result.candidateUrl === undefined
227
+ ? ""
228
+ : `<div><span>Candidate</span>${escapeHtml(result.candidateUrl)}</div>`,
229
+ ].join("");
230
+ const changes =
231
+ result.changes.length === 0
232
+ ? '<p class="clean">No differences detected.</p>'
233
+ : `<div class="changes">${result.changes.map(renderChange).join("")}</div>`;
234
+ return `<section class="target">
235
+ <header><div><span class="target-status">${escapeHtml(result.status)}</span><h2>${escapeHtml(result.key)}</h2></div><div class="urls">${urls}</div></header>
236
+ ${renderTimelines(result)}${changes}
237
+ </section>`;
238
+ }
239
+
240
+ export function renderComparisonHtml(comparison: AuditComparison): string {
241
+ const { summary } = comparison;
242
+ const outcome = summary.regressions > 0 ? "Regressions detected" : "No regressions";
243
+ const targets = comparison.results.map(renderTarget).join("\n");
244
+ return `<!doctype html>
245
+ <html lang="en">
246
+ <head>
247
+ <meta charset="utf-8">
248
+ <meta name="viewport" content="width=device-width,initial-scale=1">
249
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
250
+ <title>SSRWire comparison · ${escapeHtml(outcome)}</title>
251
+ <style>
252
+ :root{color-scheme:light dark;--bg:#f5f2eb;--panel:#fffdf8;--ink:#191c1b;--muted:#656c68;--line:#d8d5cc;--red:#b42318;--red-bg:#fff0ed;--green:#18794e;--green-bg:#ebf8f1;--blue:#1769aa;--blue-bg:#edf6ff;--track:#e3e7e4}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:48px 0 80px}.hero{display:grid;grid-template-columns:1fr auto;gap:32px;align-items:end;margin-bottom:28px}.eyebrow,.target-status{color:var(--muted);font-size:12px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}h1{font-size:clamp(34px,6vw,64px);line-height:1;margin:.18em 0}.hero p{color:var(--muted);margin:0}.outcome{border:1px solid var(--line);border-radius:18px;background:var(--panel);padding:18px 22px;min-width:260px}.outcome strong{display:block;font-size:22px}.outcome.bad strong{color:var(--red)}.counts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}.counts div{border-radius:10px;padding:8px;background:var(--bg)}.counts b{display:block;font-size:20px}.counts span{color:var(--muted);font-size:12px}.sources{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:28px}.source,.target{background:var(--panel);border:1px solid var(--line);border-radius:18px}.source{padding:16px 18px}.source span,.urls span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}.source strong{display:block;font-size:17px}.source small{color:var(--muted)}.threshold{color:var(--muted);font-size:13px;margin:-14px 0 28px}.target{overflow:hidden;margin:18px 0}.target>header{display:grid;grid-template-columns:minmax(180px,.55fr) 1fr;gap:24px;padding:22px 24px;border-bottom:1px solid var(--line)}.target h2{font-size:23px;margin:3px 0 0;overflow-wrap:anywhere}.urls{display:grid;gap:8px;overflow-wrap:anywhere}.waterfall{padding:18px 24px;border-bottom:1px solid var(--line)}.waterfall summary{cursor:pointer;font-weight:700}.waterfall summary span{color:var(--muted);font-weight:400;margin-left:8px}.agent-lane{margin:18px 0}.agent-lane h4{margin:0 0 8px}.agent-lane code,.change-heading code{color:var(--muted);font-size:12px}.timeline-row{display:grid;grid-template-columns:126px 1fr;align-items:center;margin:5px 0}.side{color:var(--muted);font-size:13px}.side small{opacity:.75}.track{position:relative;height:48px;border-radius:8px;background:linear-gradient(90deg,var(--track),transparent 1px) 0 0/10% 100%,color-mix(in srgb,var(--track) 55%,transparent)}.event{position:absolute;left:var(--position);top:calc(5px + var(--level)*20px);transform:translateX(-50%);cursor:help}.event:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.event:before{content:"";position:absolute;left:50%;top:-5px;height:42px;border-left:1px solid color-mix(in srgb,var(--ink) 22%,transparent);z-index:0}.event span{position:relative;display:block;min-width:20px;padding:1px 4px;border:1px solid var(--line);border-radius:5px;background:var(--panel);font-size:10px;font-weight:800;text-align:center;z-index:1}.missing{color:var(--muted);font-size:13px}.changes{display:grid;gap:10px;padding:18px 24px 24px}.change{border-left:4px solid var(--blue);border-radius:8px;background:var(--blue-bg);padding:12px 14px}.change.regression{border-color:var(--red);background:var(--red-bg)}.change.fixed{border-color:var(--green);background:var(--green-bg)}.change-heading{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.change-heading>span{font-size:11px;font-weight:850;letter-spacing:.08em}.change p{margin:5px 0 0}.change dl{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:10px 0 0}.change dl div{min-width:0;padding:7px 9px;border-radius:6px;background:color-mix(in srgb,var(--panel) 70%,transparent)}dt{color:var(--muted);font-size:11px;text-transform:uppercase}dd{margin:1px 0 0;overflow-wrap:anywhere}.clean{color:var(--green);font-weight:700;padding:18px 24px;margin:0}.legend{color:var(--muted);font-size:12px;margin-top:30px}.legend code{color:var(--ink)}@media(max-width:720px){main{width:min(100% - 20px,1180px);padding-top:24px}.hero,.sources,.target>header{grid-template-columns:1fr}.outcome{min-width:0}.timeline-row{grid-template-columns:1fr}.side{margin-bottom:3px}.change dl{grid-template-columns:1fr}}
253
+ @media(prefers-color-scheme:dark){:root{--bg:#151817;--panel:#1d211f;--ink:#f0f3f1;--muted:#aab2ad;--line:#363d39;--red:#ff8a80;--red-bg:#321d1a;--green:#73d6a6;--green-bg:#173125;--blue:#75bfff;--blue-bg:#172a3a;--track:#323a36}}
254
+ </style>
255
+ </head>
256
+ <body><main>
257
+ <section class="hero"><div><div class="eyebrow">SSRWire ${escapeHtml(comparison.version)} · deployment diff</div><h1>${escapeHtml(outcome)}</h1><p>Generated ${escapeHtml(comparison.generatedAt)}</p></div><aside class="outcome ${summary.regressions > 0 ? "bad" : "good"}"><strong>${summary.regressions} regression${summary.regressions === 1 ? "" : "s"}</strong><div class="counts"><div><b>${summary.fixed}</b><span>fixed</span></div><div><b>${summary.changed}</b><span>changed</span></div><div><b>${summary.unchangedTargets}</b><span>unchanged</span></div></div></aside></section>
258
+ <section class="sources"><div class="source"><span>Baseline</span><strong>${escapeHtml(comparison.baseline.label)}</strong><small>SSRWire ${escapeHtml(comparison.baseline.version)} · ${escapeHtml(comparison.baseline.generatedAt)} · repeat ${comparison.baseline.repeat}</small></div><div class="source"><span>Candidate</span><strong>${escapeHtml(comparison.candidate.label)}</strong><small>SSRWire ${escapeHtml(comparison.candidate.version)} · ${escapeHtml(comparison.candidate.generatedAt)} · repeat ${comparison.candidate.repeat}</small></div></section>
259
+ <p class="threshold">Timing regressions require both &gt;${comparison.thresholds.timingRegressionMs} ms and &gt;${comparison.thresholds.timingRegressionPercent}% median increase.</p>
260
+ ${targets || '<section class="target"><p class="clean">Both reports contain no targets.</p></section>'}
261
+ <p class="legend"><code>H</code> headers · <code>B</code> first byte · <code>T</code> title · <code>D</code> description · <code>C</code> canonical · <code>OG</code> Open Graph ready · <code>X</code> Twitter Card ready · <code>R</code> required signals ready · <code>M</code> main text · <code>✓</code> complete. Hover markers for timing, location, and byte evidence.</p>
262
+ </main></body></html>\n`;
263
+ }
264
+
265
+ export function renderComparisonReport(
266
+ comparison: AuditComparison,
267
+ format: ComparisonReportFormat,
268
+ options: ComparisonReporterOptions = {},
269
+ ): string {
270
+ if (format === "terminal") return renderComparisonTerminal(comparison, options);
271
+ if (format === "json") return renderComparisonJson(comparison);
272
+ if (format === "html") return renderComparisonHtml(comparison);
273
+
274
+ const exhaustive: never = format;
275
+ return exhaustive;
276
+ }
package/src/config.ts CHANGED
@@ -30,6 +30,10 @@ const requireSchema = z
30
30
 
31
31
  const targetObjectSchema = z
32
32
  .object({
33
+ id: z
34
+ .string()
35
+ .regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i)
36
+ .optional(),
33
37
  url: z.string().min(1),
34
38
  expectedStatus: z
35
39
  .union([
@@ -138,6 +142,7 @@ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): Au
138
142
  };
139
143
 
140
144
  return {
145
+ ...(item.id === undefined ? {} : { id: item.id }),
141
146
  url: validateHttpUrl(item.url, "target URL"),
142
147
  expectations,
143
148
  };
@@ -259,13 +264,22 @@ async function readConfig(path: string | undefined): Promise<FileConfig> {
259
264
 
260
265
  function uniqueTargets(targets: readonly AuditTarget[]): readonly AuditTarget[] {
261
266
  const seen = new Set<string>();
262
- return targets.filter((target) => {
267
+ const unique = targets.filter((target) => {
263
268
  if (seen.has(target.url)) {
264
269
  return false;
265
270
  }
266
271
  seen.add(target.url);
267
272
  return true;
268
273
  });
274
+ const ids = new Set<string>();
275
+ for (const target of unique) {
276
+ if (target.id === undefined) continue;
277
+ if (ids.has(target.id)) {
278
+ throw new ConfigError(`Target id ${target.id} is duplicated.`);
279
+ }
280
+ ids.add(target.id);
281
+ }
282
+ return unique;
269
283
  }
270
284
 
271
285
  export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWireConfig> {
package/src/index.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  export { BUILTIN_AGENTS, resolveAgent, resolveAgents } from "./agents.js";
2
2
  export { analyzeTarget, summarizeAudit } from "./analyze.js";
3
3
  export { runAudit } from "./audit.js";
4
+ export {
5
+ AUDIT_SCHEMA_VERSION,
6
+ AuditReportError,
7
+ parseAuditReport,
8
+ parseAuditReportText,
9
+ } from "./audit-report.js";
10
+ export { ComparisonError, compareAudits } from "./compare.js";
11
+ export {
12
+ renderComparisonHtml,
13
+ renderComparisonJson,
14
+ renderComparisonReport,
15
+ renderComparisonTerminal,
16
+ } from "./comparison-reporters.js";
4
17
  export { loadConfig, parseHeaderOption } from "./config.js";
5
18
  export { probeUrl } from "./http-probe.js";
6
19
  export { redactProbe } from "./redact.js";
@@ -9,9 +22,21 @@ export { createStreamInspector } from "./stream-parser.js";
9
22
  export type {
10
23
  AgentProfile,
11
24
  AgentStability,
25
+ AuditComparison,
26
+ AuditReportDescriptor,
12
27
  AuditResult,
13
28
  AuditSummary,
14
29
  AuditTarget,
30
+ CompareAuditOptions,
31
+ ComparisonChange,
32
+ ComparisonKind,
33
+ ComparisonReportFormat,
34
+ ComparisonScope,
35
+ ComparisonSummary,
36
+ ComparisonThresholds,
37
+ ComparisonTimelineEvent,
38
+ ComparisonTimelineLane,
39
+ ComparisonTimelineSnapshot,
15
40
  DocumentSignals,
16
41
  ElementLocation,
17
42
  ElementSignal,
@@ -33,6 +58,8 @@ export type {
33
58
  StabilityTimings,
34
59
  StabilityVariants,
35
60
  TargetAuditResult,
61
+ TargetComparison,
62
+ TargetComparisonStatus,
36
63
  TargetExpectations,
37
64
  TimingMark,
38
65
  TimingStats,
package/src/redact.ts CHANGED
@@ -156,6 +156,7 @@ export function redactAudit(audit: AuditResult, secrets: readonly string[]): Aud
156
156
  results: audit.results.map((result) => ({
157
157
  target: {
158
158
  ...result.target,
159
+ ...(result.target.id === undefined ? {} : { id: redactText(result.target.id, plan) }),
159
160
  url: redactText(result.target.url, plan),
160
161
  expectations: {
161
162
  ...result.target.expectations,
package/src/types.ts CHANGED
@@ -48,7 +48,7 @@ export interface DocumentSignals {
48
48
  readonly descriptions: readonly ElementSignal[];
49
49
  readonly canonicals: readonly ElementSignal[];
50
50
  readonly robots: readonly RobotsSignal[];
51
- /** Present on probes produced by SSRWire 0.3.0 and newer. */
51
+ /** Present on current probes; optional for low-level callers and older in-memory results. */
52
52
  readonly socialMetadata?: readonly SocialMetadataSignal[];
53
53
  readonly h1s: readonly ElementSignal[];
54
54
  readonly firstMainText?: ElementSignal;
@@ -135,6 +135,8 @@ export interface TargetExpectations {
135
135
  }
136
136
 
137
137
  export interface AuditTarget {
138
+ /** Stable identity used to match the same target across reports with different origins. */
139
+ readonly id?: string;
138
140
  readonly url: string;
139
141
  readonly expectations: TargetExpectations;
140
142
  }
@@ -211,6 +213,8 @@ export interface AuditSummary {
211
213
  }
212
214
 
213
215
  export interface AuditResult {
216
+ /** Version of the persisted audit-report contract, independent of the package version. */
217
+ readonly schemaVersion: 1;
214
218
  readonly version: string;
215
219
  readonly generatedAt: string;
216
220
  readonly durationMs: number;
@@ -220,3 +224,95 @@ export interface AuditResult {
220
224
  }
221
225
 
222
226
  export type ReportFormat = "terminal" | "json" | "sarif";
227
+
228
+ export type ComparisonKind = "regression" | "fixed" | "changed";
229
+
230
+ export type ComparisonScope = "target" | "agent" | "finding" | "response" | "metadata" | "timing";
231
+
232
+ export interface ComparisonChange {
233
+ readonly kind: ComparisonKind;
234
+ readonly scope: ComparisonScope;
235
+ readonly code: string;
236
+ readonly message: string;
237
+ readonly agent?: string;
238
+ readonly field?: string;
239
+ readonly baseline?: string | number | boolean;
240
+ readonly candidate?: string | number | boolean;
241
+ }
242
+
243
+ export interface ComparisonTimelineEvent {
244
+ readonly key: string;
245
+ readonly label: string;
246
+ readonly medianMs: number;
247
+ readonly location?: ElementLocation | "mixed";
248
+ readonly observedByByte?: number;
249
+ }
250
+
251
+ export interface ComparisonTimelineSnapshot {
252
+ readonly samples: number;
253
+ readonly events: readonly ComparisonTimelineEvent[];
254
+ }
255
+
256
+ export interface ComparisonTimelineLane {
257
+ readonly agent: string;
258
+ readonly label: string;
259
+ readonly baseline?: ComparisonTimelineSnapshot;
260
+ readonly candidate?: ComparisonTimelineSnapshot;
261
+ }
262
+
263
+ export type TargetComparisonStatus = "matched" | "added" | "removed";
264
+
265
+ export interface TargetComparison {
266
+ readonly key: string;
267
+ readonly id?: string;
268
+ readonly status: TargetComparisonStatus;
269
+ readonly baselineUrl?: string;
270
+ readonly candidateUrl?: string;
271
+ readonly changes: readonly ComparisonChange[];
272
+ readonly timelines: readonly ComparisonTimelineLane[];
273
+ }
274
+
275
+ export interface AuditReportDescriptor {
276
+ readonly label: string;
277
+ readonly version: string;
278
+ readonly schemaVersion: 1;
279
+ readonly generatedAt: string;
280
+ readonly repeat: number;
281
+ }
282
+
283
+ export interface ComparisonThresholds {
284
+ readonly timingRegressionMs: number;
285
+ readonly timingRegressionPercent: number;
286
+ }
287
+
288
+ export interface ComparisonSummary {
289
+ readonly targets: number;
290
+ readonly matchedTargets: number;
291
+ readonly addedTargets: number;
292
+ readonly removedTargets: number;
293
+ readonly unchangedTargets: number;
294
+ readonly regressions: number;
295
+ readonly fixed: number;
296
+ readonly changed: number;
297
+ }
298
+
299
+ export interface AuditComparison {
300
+ readonly schemaVersion: 1;
301
+ readonly kind: "comparison";
302
+ readonly version: string;
303
+ readonly generatedAt: string;
304
+ readonly baseline: AuditReportDescriptor;
305
+ readonly candidate: AuditReportDescriptor;
306
+ readonly thresholds: ComparisonThresholds;
307
+ readonly results: readonly TargetComparison[];
308
+ readonly summary: ComparisonSummary;
309
+ }
310
+
311
+ export interface CompareAuditOptions {
312
+ readonly baselineLabel?: string;
313
+ readonly candidateLabel?: string;
314
+ readonly timingRegressionMs?: number;
315
+ readonly timingRegressionPercent?: number;
316
+ }
317
+
318
+ export type ComparisonReportFormat = "terminal" | "json" | "html";