optbrew 0.1.2 → 0.2.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 (119) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/AGENT_INSTALL.md +94 -0
  5. package/CHANGELOG.md +74 -1
  6. package/README.ko.md +129 -105
  7. package/README.md +129 -105
  8. package/THIRD_PARTY_NOTICES.md +88 -0
  9. package/docs/platforms.md +45 -8
  10. package/docs/release.md +37 -4
  11. package/lib/cli.mjs +5 -2
  12. package/lib/install.mjs +2 -1
  13. package/lib/records.mjs +2 -1
  14. package/lib/transaction.mjs +12 -5
  15. package/lib/update.mjs +3 -2
  16. package/package.json +29 -9
  17. package/plugin.json +2 -2
  18. package/skills/.claude-plugin/icon.png +0 -0
  19. package/skills/.claude-plugin/plugin.json +23 -0
  20. package/skills/README.md +27 -0
  21. package/skills/optbrew/SKILL.md +223 -8
  22. package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
  23. package/skills/optbrew/bundle-manifest.json +409 -49
  24. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
  25. package/skills/optbrew/references/ai-crawlers.json +1 -1
  26. package/skills/optbrew/references/ai-crawlers.md +1 -1
  27. package/skills/optbrew/references/api.md +6 -4
  28. package/skills/optbrew/references/check-registry.json +16 -16
  29. package/skills/optbrew/references/chrome-paths.json +31 -0
  30. package/skills/optbrew/references/endpoints.json +10 -0
  31. package/skills/optbrew/references/harness-signals.json +5 -0
  32. package/skills/optbrew/references/intake.md +76 -5
  33. package/skills/optbrew/references/judging.md +233 -0
  34. package/skills/optbrew/references/limits.json +26 -0
  35. package/skills/optbrew/references/measurement.md +1 -1
  36. package/skills/optbrew/references/messages.ko.json +170 -0
  37. package/skills/optbrew/references/report.md +18 -6
  38. package/skills/optbrew/references/routing.md +2 -2
  39. package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
  40. package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
  41. package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
  42. package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
  43. package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
  44. package/skills/optbrew/references/runbooks/index.json +18 -0
  45. package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
  46. package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
  47. package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
  48. package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
  49. package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
  50. package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
  51. package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
  52. package/skills/optbrew/references/runbooks/seranking.md +38 -0
  53. package/skills/optbrew/references/schemas/contract.schema.json +42 -15
  54. package/skills/optbrew/references/scoring.md +40 -5
  55. package/skills/optbrew/references/source-analysis.md +40 -0
  56. package/skills/optbrew/references/versions.json +9 -0
  57. package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
  58. package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
  59. package/skills/optbrew/scripts/audit.mjs +10 -96
  60. package/skills/optbrew/scripts/cli.mjs +5 -2
  61. package/skills/optbrew/scripts/core/bundle.mjs +30 -13
  62. package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
  63. package/skills/optbrew/scripts/core/cdp.mjs +105 -0
  64. package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
  65. package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
  66. package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
  67. package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
  68. package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
  69. package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
  70. package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
  71. package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
  72. package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
  73. package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
  74. package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
  75. package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
  76. package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
  77. package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
  78. package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
  79. package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
  80. package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
  81. package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
  82. package/skills/optbrew/scripts/core/chrome.mjs +546 -0
  83. package/skills/optbrew/scripts/core/collector.mjs +65 -59
  84. package/skills/optbrew/scripts/core/compare.mjs +18 -1
  85. package/skills/optbrew/scripts/core/config.mjs +31 -0
  86. package/skills/optbrew/scripts/core/contracts.mjs +31 -2
  87. package/skills/optbrew/scripts/core/cwv.mjs +100 -0
  88. package/skills/optbrew/scripts/core/derive.mjs +10 -161
  89. package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
  90. package/skills/optbrew/scripts/core/facts.mjs +203 -19
  91. package/skills/optbrew/scripts/core/findings.mjs +19 -1
  92. package/skills/optbrew/scripts/core/html.mjs +41 -3
  93. package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
  94. package/skills/optbrew/scripts/core/judgments.mjs +675 -0
  95. package/skills/optbrew/scripts/core/keys.mjs +86 -0
  96. package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
  97. package/skills/optbrew/scripts/core/messages.mjs +23 -0
  98. package/skills/optbrew/scripts/core/metrics.mjs +4 -2
  99. package/skills/optbrew/scripts/core/migration.mjs +37 -0
  100. package/skills/optbrew/scripts/core/page-select.mjs +136 -0
  101. package/skills/optbrew/scripts/core/probe.mjs +83 -0
  102. package/skills/optbrew/scripts/core/protocol.mjs +38 -14
  103. package/skills/optbrew/scripts/core/render.mjs +160 -0
  104. package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
  105. package/skills/optbrew/scripts/core/report-html.mjs +198 -0
  106. package/skills/optbrew/scripts/core/report-page.mjs +14 -0
  107. package/skills/optbrew/scripts/core/report.mjs +357 -131
  108. package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
  109. package/skills/optbrew/scripts/core/scope.mjs +23 -5
  110. package/skills/optbrew/scripts/core/score.mjs +12 -4
  111. package/skills/optbrew/scripts/core/secrets.mjs +2 -0
  112. package/skills/optbrew/scripts/core/stages.mjs +694 -0
  113. package/skills/optbrew/scripts/core/transport.mjs +157 -0
  114. package/skills/optbrew/scripts/core/weights.mjs +2 -1
  115. package/skills/optbrew/scripts/key.mjs +90 -0
  116. package/skills/optbrew/scripts/preflight.mjs +176 -0
  117. package/skills/optbrew/scripts/run.mjs +81 -0
  118. package/skills/optbrew/source-lock.json +1 -1
  119. package/source-lock.json +1 -1
@@ -1,14 +1,16 @@
1
1
  import { lookup } from 'node:dns/promises';
2
2
  import { createHash } from 'node:crypto';
3
- import http from 'node:http';
4
- import https from 'node:https';
5
3
  import { isIP } from 'node:net';
6
4
  import { performance } from 'node:perf_hooks';
7
- import { assertInScope, assertUrlInScope } from './scope.mjs';
5
+ import { assertInScope, assertUrlInScope, urlTarget } from './scope.mjs';
8
6
  import { anchorLinks, canonicalLink } from './html.mjs';
7
+ import { limits, requestLimit } from './config.mjs';
8
+ import { directTransport } from './transport.mjs';
9
9
 
10
10
  const HEADER_NAMES = ['content-type', 'content-length', 'content-range', 'content-encoding', 'location', 'x-robots-tag', 'last-modified', 'etag'];
11
11
  const REDIRECTS = new Set([301, 302, 303, 307, 308]);
12
+ // An API call follows no redirect: zero hops.
13
+ const FOLLOW_NONE = 0;
12
14
  const text = value => Buffer.isBuffer(value) ? value : Buffer.from(value ?? '');
13
15
 
14
16
  function auxiliaryDocument(url) {
@@ -24,14 +26,14 @@ function undeclaredAuxiliary(url, allowed) {
24
26
  throw error;
25
27
  }
26
28
 
27
- function safeHeaders(headers) {
29
+ function safeHeaders(headers, extra = []) {
28
30
  const source = headers instanceof Headers ? Object.fromEntries(headers) : headers ?? {};
29
- return Object.fromEntries(Object.entries(source).filter(([name]) => HEADER_NAMES.includes(name.toLowerCase()))
31
+ return Object.fromEntries(Object.entries(source).filter(([name]) => HEADER_NAMES.includes(name.toLowerCase()) || extra.includes(name.toLowerCase()))
30
32
  .map(([name, value]) => [name.toLowerCase(), String(value)]));
31
33
  }
32
34
 
33
35
  /** Response header lines in wire order; repeated lines (for example two X-Robots-Tag lines) stay separate. */
34
- export function headerLines(response) {
36
+ function headerLines(response) {
35
37
  const lines = [];
36
38
  if (Array.isArray(response.rawHeaders)) {
37
39
  for (let i = 0; i + 1 < response.rawHeaders.length; i += 2) {
@@ -47,38 +49,8 @@ export function headerLines(response) {
47
49
  return lines;
48
50
  }
49
51
 
50
- /** Node's lookup callback pins the already-vetted address while TLS still verifies the URL hostname. */
51
- async function nodeTransport({ url, address, timeoutMs, maxBytes, headers = {}, signal }) {
52
- const client = url.protocol === 'https:' ? https : http;
53
- const hostname = url.hostname.replace(/^\[|\]$/g, '');
54
- return new Promise((resolve, reject) => {
55
- const req = client.request(url, {
56
- method: 'GET', agent: false, signal, headers: { 'User-Agent': 'optbrew/0.1 (bounded public audit)', Accept: '*/*', 'Accept-Encoding': 'identity', ...headers },
57
- lookup: (_hostname, options, callback) => options.all ?
58
- callback(null, [{ address: address.address, family: address.family }]) : callback(null, address.address, address.family),
59
- ...(url.protocol === 'https:' ? { rejectUnauthorized: true,
60
- ...(!isIP(hostname) ? { servername: hostname } : {}) } : {}),
61
- }, res => {
62
- const chunks = []; let size = 0;
63
- if (Number(res.headers['content-length']) > maxBytes) {
64
- req.destroy(new Error('response byte limit exceeded')); return;
65
- }
66
- res.on('data', chunk => {
67
- size += chunk.length;
68
- if (size > maxBytes) { req.destroy(new Error('response byte limit exceeded')); return; }
69
- chunks.push(chunk);
70
- });
71
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) }));
72
- res.on('error', reject);
73
- });
74
- req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')));
75
- req.on('error', reject);
76
- req.end();
77
- });
78
- }
79
-
80
52
  async function addressesFor(url, resolve) {
81
- const host = url.hostname.replace(/^\[|\]$/g, '');
53
+ const { host } = urlTarget(url);
82
54
  if (isIP(host)) return [{ address: host, family: isIP(host) }];
83
55
  const values = await resolve(host);
84
56
  return (Array.isArray(values) ? values : [values]).map(x => {
@@ -88,13 +60,20 @@ async function addressesFor(url, resolve) {
88
60
  }
89
61
 
90
62
  /** Fetch one URL with fresh DNS validation at every redirect and a pinned connection. */
91
- export async function fetchScoped(input, { context, transport = nodeTransport, resolve = host => lookup(host, { all: true, verbatim: true }), timeoutMs = 20000, maxBytes = 5242880, maxRedirects = 10, headers = {}, allowedAuxiliary = null, reserveRequest = null }) {
92
- if (![timeoutMs, maxBytes, maxRedirects].every(Number.isInteger) || timeoutMs < 1 || timeoutMs > 20000 || maxBytes < 1 || maxBytes > 5242880 || maxRedirects < 0 || maxRedirects > 10)
63
+ export async function fetchScoped(input, { context, transport = directTransport, resolve = host => lookup(host, { all: true, verbatim: true }), timeoutMs = limits().timeout_ms, maxBytes = limits().max_bytes, maxRedirects = limits().max_redirects, headers = {}, allowedAuxiliary = null, reserveRequest = null, addressVetting = 'direct', extraHeaders = [], method = 'GET', body: requestBody = null, ceiling = { timeoutMs: limits().timeout_ms, maxBytes: limits().max_bytes }, fieldData = false }) {
64
+ // A caller may ask for a lower ceiling, never a higher one than the config fixes: the crawl's limits, or the field-data limits
65
+ // for the one adapter that says it is a field-data call (`transportFetch`).
66
+ const fixed = fieldData ? { timeoutMs: limits().cwv_timeout_ms, maxBytes: limits().cwv_max_bytes } : { timeoutMs: limits().timeout_ms, maxBytes: limits().max_bytes };
67
+ if (![timeoutMs, maxBytes, maxRedirects].every(Number.isInteger) || timeoutMs < 1 || timeoutMs > Math.min(ceiling.timeoutMs, fixed.timeoutMs) ||
68
+ maxBytes < 1 || maxBytes > Math.min(ceiling.maxBytes, fixed.maxBytes) || maxRedirects < 0 || maxRedirects > limits().max_redirects)
93
69
  throw new Error('fetch limits exceed context ceiling');
70
+ // Proxy vetting skips DNS-name checks, which is only safe when the proxy transport (not a direct connection) carries the request.
71
+ if (addressVetting === 'proxy' && transport.addressVetting !== 'proxy') throw new Error('proxy address vetting requires the proxy transport');
94
72
  let url = assertUrlInScope(input, context.scope);
95
73
  if (allowedAuxiliary) undeclaredAuxiliary(url, allowedAuxiliary);
96
74
  const requested_url = String(input);
97
75
  const redirect_chain = [];
76
+ let made = 0; // requests actually sent; an answered hop is in redirect_chain, an unanswered attempt is made - chain length
98
77
  const controller = new AbortController();
99
78
  const deadline = performance.now() + timeoutMs;
100
79
  let rejectTimeout;
@@ -104,7 +83,7 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
104
83
  try {
105
84
  for (let hop = 0; hop <= maxRedirects; hop++) {
106
85
  const addresses = await withinDeadline(addressesFor(url, resolve));
107
- try { assertInScope(url.href, context.scope, addresses); }
86
+ try { assertInScope(url.href, context.scope, addresses, { addressVetting }); }
108
87
  catch (error) { error.code = 'scope_rejected'; error.redirect_chain = redirect_chain; throw error; }
109
88
  // Pick only a vetted address. The transport must use this address, not resolve again.
110
89
  const remainingMs = Math.ceil(deadline - performance.now());
@@ -116,18 +95,23 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
116
95
  error.redirect_chain = redirect_chain;
117
96
  throw error;
118
97
  }
119
- const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal }));
98
+ made++;
99
+ const response = await withinDeadline(transport({ url, address: addresses[0], timeoutMs: remainingMs, maxBytes, headers, signal: controller.signal, method, body: requestBody }));
120
100
  if (!Number.isInteger(response?.status) || response.status < 100 || response.status > 599) throw new Error('invalid transport status');
121
101
  const body = text(response.body);
122
102
  if (body.length > maxBytes) throw new Error('response byte limit exceeded');
123
- const savedHeaders = safeHeaders(response.headers);
103
+ const savedHeaders = safeHeaders(response.headers, extraHeaders);
124
104
  if (REDIRECTS.has(response.status) && savedHeaders.location) {
125
- if (hop === maxRedirects) throw new Error('redirect limit exceeded');
105
+ if (hop === maxRedirects) {
106
+ redirect_chain.push({ url: url.href, status: response.status, location: '[limit]' });
107
+ throw new Error('redirect limit exceeded');
108
+ }
126
109
  let next;
127
110
  try { next = assertUrlInScope(new URL(savedHeaders.location, url).href, context.scope); }
128
111
  catch {
129
112
  const error = new Error('redirect outside scope');
130
113
  error.code = 'redirect_outside_scope';
114
+ try { error.redirect_to = new URL(savedHeaders.location, url).href; } catch { /* an unparsable location names no site */ }
131
115
  error.redirect_chain = [...redirect_chain, { url: url.href, status: response.status, location: '[blocked]' }];
132
116
  throw error;
133
117
  }
@@ -149,10 +133,29 @@ export async function fetchScoped(input, { context, transport = nodeTransport, r
149
133
  };
150
134
  }
151
135
  throw new Error('redirect limit exceeded');
152
- } catch (error) { error.redirect_chain ??= [...redirect_chain]; throw error; }
136
+ } catch (error) {
137
+ error.redirect_chain ??= [...redirect_chain];
138
+ error.unanswered = Math.max(0, made - error.redirect_chain.length);
139
+ throw error;
140
+ }
153
141
  finally { clearTimeout(timer); }
154
142
  }
155
143
 
144
+ /**
145
+ * A fetch-like function over the transport `selectTransport` chose, for API calls the audit makes itself (field CWV): the
146
+ * host is resolved and vetted exactly as `fetchScoped` does it (direct: pinned vetted address; proxy: the proxy resolves,
147
+ * literals are vetted), a redirect is never followed, and the limits are the caller's, not the page collection's.
148
+ * It answers `{ status, text() }` and throws what `fetchScoped` throws.
149
+ */
150
+ export function transportFetch(selected, { timeoutMs, maxBytes }) {
151
+ return async (url, { method = 'GET', headers = {}, body = null } = {}) => {
152
+ const result = await fetchScoped(url, { context: { scope: { allowed: [{ origin: new URL(url).origin }] } }, transport: selected.transport,
153
+ resolve: selected.resolve, addressVetting: selected.address_vetting, method, headers, body, maxRedirects: FOLLOW_NONE,
154
+ timeoutMs, maxBytes, ceiling: { timeoutMs, maxBytes }, fieldData: true });
155
+ return { status: result.status, text: async () => result.body.toString('utf8') };
156
+ };
157
+ }
158
+
156
159
  /** Index of the declared robots.txt artifact on the same origin as the page, as the audit and the report both select it. */
157
160
  export function declaredRobotsIndex(artifacts, declaredRobots, pageFinalUrl) {
158
161
  return artifacts.findIndex(x => {
@@ -170,11 +173,11 @@ export function links(html, base) {
170
173
  }
171
174
 
172
175
  /** Bounded crawl of fixed protocol URLs and their in-scope links. */
173
- export async function collect(context, { transport, resolve } = {}) {
176
+ export async function collect(context, { transport, resolve, addressVetting } = {}) {
174
177
  const scope = context?.scope;
175
- const limit = Math.min(scope?.max_urls ?? 50, 50);
176
- const depthLimit = Math.min(scope?.max_depth ?? 3, 3);
177
- const concurrency = Math.min(scope?.concurrency ?? 2, 2);
178
+ const limit = requestLimit(scope);
179
+ const depthLimit = Math.min(scope?.max_depth ?? limits().max_depth, limits().max_depth);
180
+ const concurrency = Math.min(scope?.concurrency ?? limits().concurrency, limits().concurrency);
178
181
  if (![limit, depthLimit, concurrency].every(Number.isInteger) || limit < 1 || depthLimit < 0 || concurrency < 1)
179
182
  throw new Error('invalid collection limits');
180
183
  const initial = context.protocol.page_types.flatMap(type => type.urls.map(ref => ref.normalized));
@@ -200,39 +203,42 @@ export async function collect(context, { transport, resolve } = {}) {
200
203
  if (depth > depthLimit) { skip(normalized, 'depth_limit'); return; }
201
204
  queue.push({ url: normalized, depth, auxiliaryResource });
202
205
  };
203
- initial.forEach(url => enqueue(url, 0));
206
+ if (auxiliary.some(resource => initial.includes(resource.normalized))) throw new Error('auxiliary resource duplicates selected URL');
207
+ // Declared resources (robots, sitemap) are evidence for every page, so they go first: a selected page's redirect hop
208
+ // takes a request the page budget did not count, and that overrun must cost a page, never the site-wide evidence.
204
209
  for (const resource of auxiliary) {
205
- if (seen.has(resource.normalized)) throw new Error('auxiliary resource duplicates selected URL');
206
210
  const normalized = assertUrlInScope(resource.normalized, scope).href;
207
211
  if (normalized !== resource.normalized || assertUrlInScope(resource.original, scope).href !== normalized)
208
212
  throw new Error('auxiliary resource URL mismatch');
209
213
  enqueue(normalized, 0, true);
210
214
  }
215
+ initial.forEach(url => enqueue(url, 0));
211
216
  const artifacts = []; let cursor = 0; let requestCount = 0;
212
217
  async function worker() {
213
218
  while (cursor < queue.length) {
214
219
  const item = queue[cursor++];
215
220
  if (requestCount >= limit) { skip(item.url, 'url_limit'); continue; }
216
221
  let used = 0;
217
- // The budget decides whether a fetch may start. A redirect chain that has started is allowed to finish (at most
218
- // maxRedirects further requests), so a chain is never cut in the middle by the limit.
222
+ // The budget is a hard ceiling: every request, redirect hops included, takes one slot immediately before it is sent.
223
+ // A chain the budget stops is a request_limit unknown whose answered hops are recorded.
219
224
  const reserveRequest = () => {
220
- if (!used && requestCount >= limit) return false;
225
+ if (requestCount >= limit) return false;
221
226
  requestCount++; used++;
222
227
  return true;
223
228
  };
224
229
  let artifact;
225
- // A chain is at most min(10, limit) redirects long, so the requests a started chain adds beyond the limit scale with
226
- // the limit: at most concurrency x min(10, limit) in all.
227
- try { artifact = await fetchScoped(item.url, { context, transport, resolve, allowedAuxiliary: allowedDocuments, reserveRequest, maxRedirects: Math.min(10, limit) }); }
230
+ try { artifact = await fetchScoped(item.url, { context, transport, resolve, addressVetting, allowedAuxiliary: allowedDocuments, reserveRequest }); }
228
231
  catch (error) {
229
232
  const reason = ['scope_rejected', 'redirect_outside_scope', 'auxiliary_undeclared', 'request_limit'].includes(error.code) ? error.code : 'fetch_failed';
230
233
  if (reason === 'request_limit' && !used) { skip(item.url, 'url_limit'); continue; }
231
234
  if (reason !== 'fetch_failed') skipped.push({ url: item.url, reason });
232
- // A failed fetch has no hashed response record, so the requests it made cannot be proven later: it counts as one.
233
- ledger.push({ url: item.url, requests: 1, outcome: 'error', skipped_reason: reason !== 'fetch_failed' ? reason : null, error: reason });
235
+ // The requests a failed fetch made are its answered hops (kept in the hashed failure record) plus at most one
236
+ // attempt that got no response, so the ledger can prove them.
237
+ const chain = error.redirect_chain ?? [];
238
+ const unanswered = error.unanswered ?? 0;
239
+ ledger.push({ url: item.url, requests: chain.length + unanswered, outcome: 'error', skipped_reason: reason !== 'fetch_failed' ? reason : null, error: reason });
234
240
  artifact = { requested_url: item.url, normalized_url: item.url, final_url: null, status: null,
235
- headers: {}, header_lines: [], redirect_chain: error.redirect_chain ?? [], raw_html_ref: null, content_hash: null, capability: 'none',
241
+ headers: {}, header_lines: [], redirect_chain: chain, unanswered, raw_html_ref: null, content_hash: null, capability: 'none',
236
242
  source_ref: 'optbrew:collector', evidence_ref: null, error: reason, body: Buffer.alloc(0) };
237
243
  }
238
244
  if (!artifact.error) ledger.push({ url: item.url, requests: used, outcome: 'response', skipped_reason: null, error: null });
@@ -2,6 +2,7 @@ import { validateEntity } from './contracts.mjs';
2
2
  import { freezeProtocol } from './protocol.mjs';
3
3
  import { compileUnits } from './weights.mjs';
4
4
  import { score } from './score.mjs';
5
+ import { olderVersion } from './migration.mjs';
5
6
 
6
7
  const close = (a, b) => Math.abs(a - b) <= 1e-9;
7
8
  const rawScore = result => result.M === 0 ? null : 100 * result.N / result.M;
@@ -9,11 +10,27 @@ const rawCoverage = result => result.A === 0 ? null : 100 * result.M / result.A;
9
10
  const difference = (before, after) => before === null || after === null ? null : after - before;
10
11
  const relative = (before, after) => before === null || after === null || close(before, 0) ? null : (after - before) / before;
11
12
 
13
+ /**
14
+ * An input made by an older optbrew was a valid input then, so it is reported as not comparable in plain words instead of
15
+ * failing the schema. It is found by its version labels, never by the shape the older protocol lacked.
16
+ */
17
+ const olderReason = (input, label) => {
18
+ const older = olderVersion(input);
19
+ return older && `the ${label} was made with ${older.name} ${older.found}, which is older than the current one; run a new audit to compare`;
20
+ };
21
+
12
22
  /** Compare two ScoreInputs only when their frozen conditions and applicable units match. */
13
23
  export function compare(before, after) {
24
+ const oldBaseline = olderReason(before, 'baseline');
25
+ if (after === null || after === undefined) {
26
+ if (!oldBaseline) { validateEntity('ScoreInput', before); freezeProtocol(before.run_context); }
27
+ return { status: 'no_after', score_deltas: [], reasons: ['after_missing'] };
28
+ }
29
+ if (oldBaseline) return { status: 'not_comparable', score_deltas: [], reasons: [oldBaseline] };
30
+ const oldFollowup = olderReason(after, 'follow-up');
31
+ if (oldFollowup) return { status: 'not_comparable', score_deltas: [], reasons: [oldFollowup] };
14
32
  validateEntity('ScoreInput', before);
15
33
  const baseline = freezeProtocol(before.run_context);
16
- if (after === null || after === undefined) return { status: 'no_after', score_deltas: [], reasons: ['after_missing'] };
17
34
  validateEntity('ScoreInput', after);
18
35
  const followup = freezeProtocol(after.run_context);
19
36
  const reasons = [];
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const cache = new Map();
4
+
5
+ /** The one place that reads a config block: first call parses it, every call returns the same frozen object. */
6
+ const block = name => () => {
7
+ if (!cache.has(name)) {
8
+ const file = new URL(`../../references/${name}.json`, import.meta.url);
9
+ cache.set(name, Object.freeze(JSON.parse(readFileSync(file, 'utf8'))));
10
+ }
11
+ return cache.get(name);
12
+ };
13
+
14
+ export const limits = block('limits');
15
+ export const versions = block('versions');
16
+ export const endpoints = block('endpoints');
17
+ export const chromePaths = block('chrome-paths');
18
+ export const harnessSignals = block('harness-signals');
19
+ export const messagesKo = block('messages.ko');
20
+
21
+ /** The request ceiling of one run: the start question's `max_urls` (default 50), never above the cap (200). */
22
+ export const requestLimit = scope => Math.min(scope?.max_urls ?? limits().max_urls_default, limits().max_urls_cap);
23
+
24
+ export const checkRegistry = block('check-registry');
25
+ /**
26
+ * Whether a family matters only for pages meant to be indexed (`public_only` in `check-registry.json`, the one list): a page with
27
+ * `public_indexing:false` has no unit of it, no verdict and no fix. Protocol, unit compilation and the derivation all read this.
28
+ */
29
+ export const publicOnlyFamily = familyId => checkRegistry().families.find(family => family.family_id === familyId)?.public_only === true;
30
+ /** The judgment thresholds one family's check block reads from `check-registry.json`. */
31
+ export const thresholds = familyId => checkRegistry().families.find(family => family.family_id === familyId).thresholds;
@@ -1,10 +1,13 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { limits, versions } from './config.mjs';
2
3
 
3
4
  const schema = JSON.parse(readFileSync(new URL('../../references/schemas/contract.schema.json', import.meta.url)));
4
5
  const registry = JSON.parse(readFileSync(new URL('../../references/check-registry.json', import.meta.url)));
5
6
  const families = new Map(registry.families.map(family => [family.family_id, family]));
6
7
  const tolerance = 1e-9;
7
- const cwv = { 'lcp-field': [2500, 4000, 'ms'], 'inp-field': [200, 500, 'ms'], 'cls-field': [0.1, 0.25, 'unitless'] };
8
+ // [good, poor, unit] of each field CWV family, read from the registry so the thresholds are part of the evaluation source.
9
+ const cwv = Object.fromEntries(registry.families.filter(family => family.thresholds?.good !== undefined)
10
+ .map(({ family_id, thresholds }) => [family_id, [thresholds.good, thresholds.poor, thresholds.unit]]));
8
11
 
9
12
  export class ContractError extends Error {
10
13
  constructor(message) { super(message); this.name = 'ContractError'; }
@@ -26,13 +29,19 @@ function jsonSafe(value, path = '$', seen = new Set()) {
26
29
  }
27
30
  }
28
31
 
32
+ /** Whether a value satisfies a rule; the `if` and `not` keywords test this instead of failing. */
33
+ function matches(rule, value) {
34
+ try { checkRule(rule, value, '$'); return true; } catch (error) { if (error instanceof ContractError) return false; throw error; }
35
+ }
36
+
29
37
  function checkRule(rule, value, path) {
30
38
  if (rule.$ref) return checkRule(schema.$defs[rule.$ref.split('/').at(-1)], value, path);
31
39
  if ('const' in rule && value !== rule.const) fail(path, `expected ${JSON.stringify(rule.const)}`);
40
+ if (rule.not && matches(rule.not, value)) fail(path, 'value not allowed here');
32
41
  if (rule.enum && !rule.enum.includes(value)) fail(path, 'unrecognized value');
33
42
  if (rule.type) {
34
43
  const types = Array.isArray(rule.type) ? rule.type : [rule.type];
35
- const valid = types.some(type => type === 'null' ? value === null : type === 'array' ? Array.isArray(value) : type === 'object' ? plainObject(value) : typeof value === type);
44
+ const valid = types.some(type => type === 'null' ? value === null : type === 'integer' ? Number.isInteger(value) : type === 'array' ? Array.isArray(value) : type === 'object' ? plainObject(value) : typeof value === type);
36
45
  if (!valid) fail(path, `expected ${types.join(' or ')}`);
37
46
  }
38
47
  if (typeof value === 'number') {
@@ -44,7 +53,10 @@ function checkRule(rule, value, path) {
44
53
  if (rule.additionalProperties === false) for (const key of Object.keys(value))
45
54
  if (!Object.hasOwn(rule.properties ?? {}, key)) fail(`${path}.${key}`, 'unexpected field');
46
55
  for (const [key, child] of Object.entries(rule.properties ?? {})) if (Object.hasOwn(value, key)) checkRule(child, value[key], `${path}.${key}`);
56
+ if (rule.if && rule.then && matches(rule.if, value)) checkRule(rule.then, value, path);
47
57
  }
58
+ if (typeof value === 'string' && rule.minLength !== undefined && value.length < rule.minLength) fail(path, 'too short');
59
+ if (Array.isArray(value) && rule.minItems !== undefined && value.length < rule.minItems) fail(path, 'too few items');
48
60
  if (Array.isArray(value) && rule.items) value.forEach((item, i) => checkRule(rule.items, item, `${path}[${i}]`));
49
61
  }
50
62
 
@@ -131,12 +143,29 @@ function checkMetric(value) {
131
143
  /(?:^|:)citation_response_rate$/.test(value.metric_id)) fail(path, 'positive-only sample cannot estimate all-question citation rate');
132
144
  }
133
145
 
146
+ /** The request limit a run may ask for: a whole number from 1 up to the configured cap. */
147
+ function checkMaxUrls(value, path) {
148
+ if (!Number.isInteger(value) || value < 1 || value > limits().max_urls_cap) fail(path, `must be a whole number from 1 to ${limits().max_urls_cap}`);
149
+ }
150
+
134
151
  /** Validate a persisted entity and return its original value. */
135
152
  export function validateEntity(typeName, value) {
136
153
  const rule = schema.$defs[typeName];
137
154
  if (!rule) throw new ContractError(`unknown entity type: ${typeName}`);
138
155
  jsonSafe(value);
156
+ // Answers are written by an agent from the installed skill's text: a different contract version means the text and the scripts disagree.
157
+ // Checked before the schema so the user reads one plain sentence, not a schema path.
158
+ if (typeName === 'IntakeAnswers' && value?.schema_version !== versions().contract_schema)
159
+ fail('IntakeAnswers.schema_version', `the answers file ${value?.schema_version === undefined ? 'has no schema_version' : `says ${JSON.stringify(value.schema_version)}`} but this optbrew needs ${JSON.stringify(versions().contract_schema)}; copy the contract_schema value from references/versions.json`);
139
160
  checkRule(rule, value, typeName);
161
+ if (typeName === 'RunContext' && value.scope?.max_urls !== undefined) checkMaxUrls(value.scope.max_urls, 'RunContext.scope.max_urls');
162
+ if (typeName === 'IntakeAnswers') {
163
+ checkMaxUrls(value.max_urls, 'IntakeAnswers.max_urls');
164
+ // The start question is "sample or raise the limit": the two answers and the number must agree.
165
+ const raised = value.sample_mode === 'raise';
166
+ if (raised ? value.max_urls <= limits().max_urls_default : value.max_urls !== limits().max_urls_default)
167
+ fail('IntakeAnswers.max_urls', raised ? 'a raised limit must be above the default' : `a sample uses the default limit (${limits().max_urls_default}); answer "raise" to go higher`);
168
+ }
140
169
  if (typeName === 'CheckObservation') checkObservation(value);
141
170
  if (typeName === 'ScoreInput') value.observations.forEach(checkObservation);
142
171
  if (typeName === 'MetricObservation') checkMetric(value);
@@ -0,0 +1,100 @@
1
+ import { endpoints, limits } from './config.mjs';
2
+
3
+ export const HTTP_OK = 200;
4
+ const HTTP_NOT_FOUND = 404;
5
+ const NOT_FOUND = /chrome ux report data not found/i;
6
+ // Google API keys are letters, digits, ".", "_" and "-" (AI Studio's AQ. keys have a dot); anything else is not a key optbrew can send, and a short string could not be removed safely.
7
+ const KEY_SHAPE = /^[A-Za-z0-9._-]{20,}$/;
8
+ // CrUX is asked for phones, as PageSpeed Insights is (strategy=mobile in endpoints.json).
9
+ const FORM_FACTOR = 'PHONE';
10
+ // CLS counts shifts in session windows: a gap of 1 s ends a window and a window ends after 5 s (the web.dev definition).
11
+ const SESSION_GAP_MS = 1000;
12
+ const SESSION_CAP_MS = 5000;
13
+
14
+ /** `text` with the key removed, in the form it was sent and in its URL-encoded form. */
15
+ const without = (text, key) => key ? [key, encodeURIComponent(key)].reduce((rest, secret) => rest.split(secret).join(''), String(text)) : String(text);
16
+
17
+ /** One request. A network failure is a result too (`status: null`), so a dead network leaves evidence instead of an exception. */
18
+ async function ask(fetchFn, url, init, key) {
19
+ try {
20
+ const response = await fetchFn(url, init);
21
+ return { status: response.status, text: without(await response.text(), key) };
22
+ } catch (error) {
23
+ return { status: null, error: without(error?.message ?? error, key) };
24
+ }
25
+ }
26
+
27
+ /** Store what the source answered, cleaned of the key, and list it in the index's `cwv` section. `key` says what became of the user's key. */
28
+ async function keep(store, source, origin, keyState, result) {
29
+ let body = result.text;
30
+ try { body = JSON.parse(result.text); } catch { /* a body that is not JSON is kept as text */ }
31
+ const record = { source, origin, key: keyState, status: result.status, ...(result.error === undefined ? { body } : { error: result.error }) };
32
+ const { ref, sha256 } = await store.write('cwv', `${JSON.stringify(record, null, 2)}\n`);
33
+ await store.append('cwv', { source, evidence_ref: ref, sha256 });
34
+ }
35
+
36
+ /**
37
+ * Field Core Web Vitals of one origin, stored as evidence and never judged here. With the user's own Google key the
38
+ * Chrome UX Report is asked first and PageSpeed Insights only when CrUX has no data for the origin; without a key (or with
39
+ * one that is not shaped like a Google key, which the record says) PageSpeed Insights is tried `psi_attempts` times, keyless.
40
+ * `fetchFn(url, { method, headers, body })` answers `{ status, text() }`: pass `transportFetch` of the run's transport, so a
41
+ * proxy-only network works and the host is vetted. The key travels in the `x-goog-api-key` header only: no endpoint and no URL
42
+ * holds it, and every stored body or error has it removed.
43
+ */
44
+ export async function collectCwv({ origin, key = null, fetchFn, store }) {
45
+ if (typeof fetchFn !== 'function') throw new TypeError('collectCwv needs fetchFn');
46
+ const usable = typeof key === 'string' && KEY_SHAPE.test(key);
47
+ const keyState = key ? (usable ? 'used' : 'key_ignored_malformed') : 'absent';
48
+ const auth = usable ? { 'x-goog-api-key': key } : {};
49
+ const secret = usable ? key : null;
50
+ if (usable) {
51
+ const crux = await ask(fetchFn, endpoints().crux, { method: 'POST', headers: { 'content-type': 'application/json', ...auth },
52
+ body: JSON.stringify({ origin, formFactor: FORM_FACTOR }) }, secret);
53
+ await keep(store, 'crux', origin, keyState, crux);
54
+ if (!(crux.status === HTTP_NOT_FOUND && NOT_FOUND.test(crux.text))) return;
55
+ }
56
+ for (let attempt = 0; attempt < limits().psi_attempts; attempt++) {
57
+ const url = new URL(endpoints().psi);
58
+ url.searchParams.set('url', `${origin}/`);
59
+ const psi = await ask(fetchFn, url.href, { method: 'GET', headers: auth }, secret);
60
+ await keep(store, 'psi', origin, keyState, psi);
61
+ if (psi.status === HTTP_OK) return;
62
+ }
63
+ }
64
+
65
+ /** Evaluated in the page: the buffered LCP and layout-shift entries, as plain data. */
66
+ const READ_LAB = `new Promise(resolve => {
67
+ const lcp = [], shifts = [];
68
+ const addLcp = entry => lcp.push(entry.startTime);
69
+ const addShift = entry => shifts.push({ startTime: entry.startTime, value: entry.value, hadRecentInput: entry.hadRecentInput });
70
+ const watch = (type, add) => {
71
+ const observer = new PerformanceObserver(list => list.getEntries().forEach(add));
72
+ observer.observe({ type, buffered: true });
73
+ return () => observer.takeRecords().forEach(add);
74
+ };
75
+ const drain = [watch('largest-contentful-paint', addLcp), watch('layout-shift', addShift)];
76
+ // Buffered entries arrive in a task of their own; one more task later they have all been delivered.
77
+ setTimeout(() => { drain.forEach(run => run()); resolve(JSON.stringify({ lcp, shifts })); }, 0);
78
+ })`;
79
+
80
+ /** The largest sum of shift values over session windows; shifts right after user input do not count. */
81
+ function layoutShift(shifts) {
82
+ let best = 0, sum = 0, windowStart = null, last = null;
83
+ for (const { startTime, value } of shifts.filter(shift => !shift.hadRecentInput).sort((a, b) => a.startTime - b.startTime)) {
84
+ if (windowStart === null || startTime - last > SESSION_GAP_MS || startTime - windowStart > SESSION_CAP_MS) { sum = 0; windowStart = startTime; }
85
+ sum += value;
86
+ last = startTime;
87
+ best = Math.max(best, sum);
88
+ }
89
+ return best;
90
+ }
91
+
92
+ /**
93
+ * Lab Core Web Vitals of a page a `scopedPage` has already loaded, read with PerformanceObserver. `lcp` is the last
94
+ * largest-contentful-paint entry in ms (null when the page painted none), `cls` the layout shift score. The caller keeps
95
+ * the result in `RunState.lab_cwv`: it is shown as reference only and never becomes an observation.
96
+ */
97
+ export async function labCwv({ page }) {
98
+ const { lcp, shifts } = JSON.parse(await page.evaluate(READ_LAB));
99
+ return { lcp: lcp.length ? lcp.at(-1) : null, cls: layoutShift(shifts) };
100
+ }