@nextcommerce/campaigns-os 1.50.0 → 1.53.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 (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +282 -14
  13. package/contracts/release-ledger.json +1233 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
@@ -228,7 +228,7 @@ export function buildPolishCaptureIntegrity(captureProjection) {
228
228
  };
229
229
  }
230
230
 
231
- function resolvedResource(value, { baseUrl } = {}) {
231
+ export function resolvedResource(value, { baseUrl } = {}) {
232
232
  const resolved = resolvedCaptureUrl(value, { baseUrl });
233
233
  return {
234
234
  ...resolved,
@@ -12,6 +12,13 @@ export const POLISH_BROWSER_STARTUP_DEADLINE_MS = 45_000;
12
12
  export const POLISH_CAPTURE_CELL_DEADLINE_MS = 55_000;
13
13
  export const POLISH_CAPTURE_CLOSE_DEADLINE_MS = 10_000;
14
14
  export const POLISH_CAPTURE_STARTUP_DEADLINE_MS = 55_000;
15
+ // Readability bounds, beside the capture deadlines above: one cell's probe,
16
+ // every probe of a run, every crop of a run, and all readability work a run
17
+ // adds (readability-only navigation, probes and crops).
18
+ export const READABILITY_PROBE_CELL_MS = 1_500;
19
+ export const READABILITY_PROBE_RUN_MS = 120_000;
20
+ export const READABILITY_CROP_RUN_MS = 30_000;
21
+ export const READABILITY_ADDED_RUN_MS = 300_000;
15
22
 
16
23
  export function boundedPolishDeadline(value, fallback) {
17
24
  return Number.isSafeInteger(value) && value > 0 && value <= fallback ? value : fallback;
@@ -0,0 +1,492 @@
1
+ // Media weight and hosting: the sibling stages.polish.evidence.visual_review
2
+ // .media_weight record, built from the same Polish page-load observation as
3
+ // page_load, and the rules every reader re-derives its results with.
4
+ //
5
+ // The record holds measurements only. Its results are never stored: readers
6
+ // (src/qc-results.mjs readMediaWeight) check the record against its page_load
7
+ // capture first, then call MEDIA_WEIGHT_QC_RULES.evaluate on each cell
8
+ // (reached through src/qc-check-registry.mjs). The record stays out of the
9
+ // page_load capture, whose projection and integrity are unchanged.
10
+ //
11
+ // Per cell (one route × viewport):
12
+ // - resources[]: one per requested href, covering every ledger entry of the
13
+ // capture once, each with its redirect chain in transfer order (requested
14
+ // href first, final hop last); every byte field, `measurement`, `failed` and
15
+ // `final_origin_equal` describe the final hop's ledger entry;
16
+ // - images[]: the <img> elements the image probe read after network
17
+ // observation closed (at most 512), with natural and rendered size,
18
+ // object-fit, loading and a hidden-ancestor flag; when the probe did not
19
+ // complete each image keeps only its identity and its geometry is null;
20
+ // - videos[]: one per page_load <video> element, with the ledger entries it
21
+ // fetched and whether a declared source has the document's origin.
22
+ //
23
+ // Two requested hrefs that share a redirect or final hop cannot each own the
24
+ // shared entry: the producer lists both chains as observed, and the reader,
25
+ // which requires every ledger entry in exactly one chain, reads that cell as
26
+ // evidence_not_reproducible (never pass). A requested href whose chain is
27
+ // ambiguous on any hop (bindLedgerChain: one URL answered once and
28
+ // redirected once anywhere in the chain, or a hop that is one chain's final
29
+ // and another chain's start) is bound to none: the producer lists its ledger
30
+ // entries as one-hop chains, the same for every request order, and the
31
+ // reader refuses that cell the same way.
32
+ import {
33
+ captureOrigin,
34
+ captureProblemRecordCode,
35
+ mediaFetchedResources,
36
+ resolvedResource,
37
+ resourceLedgerSort,
38
+ responseRecordResponses,
39
+ } from "./polish-capture.mjs";
40
+ import { MEDIA_WEIGHT_SCHEMA, QC_PRODUCERS, bindLedgerChain, mediaChainBinder, mediaWeightIntegrity } from "./qc-results.mjs";
41
+
42
+ export const MEDIA_WEIGHT_PRODUCER = QC_PRODUCERS.polish;
43
+
44
+ export const MEDIA_WEIGHT_THRESHOLDS = Object.freeze({ image_bytes: 500_000, oversize_factor: 2.0, min_natural_area: 250_000 });
45
+
46
+ export const MEDIA_WEIGHT_VOCABULARY = Object.freeze({
47
+ capture_status: Object.freeze(["complete", "incomplete"]),
48
+ probe_status: Object.freeze(["complete", "document_context_changed", "probe_timeout", "image_cap_reached", "probe_budget_exhausted"]),
49
+ measurement: Object.freeze(["complete", "lower_bound", "unmeasured", "cached"]),
50
+ object_fit: Object.freeze(["fill", "contain", "cover", "none", "scale-down"]),
51
+ loading: Object.freeze(["eager", "lazy", "auto"]),
52
+ });
53
+
54
+ // The image probe's cost bounds: at most 512 <img> and 500 ms per cell, and
55
+ // at most 10 s of probe time per Polish run. Each cell's probe runs inside
56
+ // the smaller of the cell bound and the run budget left, and the time it
57
+ // takes is charged to the run budget. Cells that start after the run budget
58
+ // is spent start no probe step (probe_budget_exhausted, no images, so their
59
+ // oversize result is the one keyed "cell"). A probe its bound cuts before
60
+ // its one read returns names no image either: its oversize result is the
61
+ // one keyed "cell", with the cut's probe status.
62
+ export const MEDIA_PROBE_LIMITS = Object.freeze({ imageCap: 512, cellBoundMs: 500, runBudgetMs: 10_000 });
63
+
64
+ // A probe clock ({ now(), sleep(ms) }) handed in by a caller; the adapter
65
+ // uses performance.now() and setTimeout otherwise.
66
+ export const isProbeClock = (value) => typeof value?.now === "function" && typeof value?.sleep === "function";
67
+
68
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
69
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
70
+ const isPair = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Producer
74
+
75
+ // The page_load ledger's resource_id for a URL (the sha256 of its canonical
76
+ // href, query included), through the capture's own resolver so it matches the
77
+ // ledger exactly. Null for a URL the ledger cannot hold (data:, blob:,
78
+ // unresolvable, too long).
79
+ function ledgerResourceId(url, documentUrl) {
80
+ return isNonEmptyString(url) ? resolvedResource(url, { baseUrl: documentUrl }).resource_id : null;
81
+ }
82
+
83
+ // The measurement class of a ledger entry, first match (contract 1.0 Polish
84
+ // reader checks): cached, unmeasured, lower_bound, complete.
85
+ function measurementOf(entry) {
86
+ if (entry.cache_request_count > 0) return "cached";
87
+ if (entry.unmeasured_request_count > 0) return "unmeasured";
88
+ if (entry.canceled_request_count > 0 || entry.partial_request_count > 0) return "lower_bound";
89
+ return "complete";
90
+ }
91
+
92
+ // Each requested href's observed hop sequences in transfer order, from the
93
+ // collector's response records (a redirect chain record keeps its hops in
94
+ // order; the ledger does not), keyed by the requested href's resource_id. A
95
+ // sequence whose hops do not all resolve to ledger entries is left out.
96
+ function observedSequences(responses, byId, documentUrl) {
97
+ const sequences = new Map();
98
+ for (const record of Array.isArray(responses) ? responses : []) {
99
+ if (!isPlainObject(record) || captureProblemRecordCode(record)) continue;
100
+ const hops = responseRecordResponses(record).map((hop) => ({
101
+ resource_id: ledgerResourceId(hop?.url, documentUrl),
102
+ status: Number.isInteger(hop?.status) ? hop.status : null,
103
+ mime_type: typeof hop?.mime_type === "string" ? hop.mime_type.toLowerCase() : null,
104
+ }));
105
+ if (!hops.length || hops.some((hop) => !byId.has(hop.resource_id))) continue;
106
+ if (!sequences.has(hops[0].resource_id)) sequences.set(hops[0].resource_id, []);
107
+ sequences.get(hops[0].resource_id).push(hops);
108
+ }
109
+ return sequences;
110
+ }
111
+
112
+ // Orders two observed hop sequences hop by hop: by resource_id, then status,
113
+ // then MIME type (a missing status or type first).
114
+ function compareSequences(a, b) {
115
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
116
+ const order = a[index].resource_id.localeCompare(b[index].resource_id)
117
+ || (a[index].status ?? -1) - (b[index].status ?? -1)
118
+ || (a[index].mime_type ?? "").localeCompare(b[index].mime_type ?? "");
119
+ if (order) return order;
120
+ }
121
+ return a.length - b.length;
122
+ }
123
+
124
+ // The request chain of each requested href, keyed by its resource_id. The
125
+ // ledger decides which chain an href has (bindLedgerChain, the reader's own
126
+ // rule, so the producer and the reader cannot disagree and request order
127
+ // does not matter); the response records only order its hops. An href keeps
128
+ // the observed sequence with exactly the bound hop set and final hop, the
129
+ // least one by compareSequences when several do. An href the ledger does not
130
+ // bind to exactly one chain (ambiguous) keeps none: its entries are listed as
131
+ // one-hop chains below, which the reader refuses.
132
+ function requestChains(entries, byId, responses, documentUrl) {
133
+ const chains = new Map();
134
+ const sequences = observedSequences(responses, byId, documentUrl);
135
+ for (const [requestedId, candidates] of sequences) {
136
+ const binding = bindLedgerChain(requestedId, entries, byId);
137
+ if (binding.status !== "bound") continue;
138
+ const matching = candidates.filter((hops) => hops.at(-1).resource_id === binding.final.resource_id
139
+ && new Set(hops.map((hop) => hop.resource_id)).size === hops.length
140
+ && hops.length === binding.hops.size
141
+ && hops.every((hop) => binding.hops.has(hop.resource_id)));
142
+ if (matching.length) chains.set(requestedId, matching.reduce((least, hops) => (compareSequences(hops, least) < 0 ? hops : least)));
143
+ }
144
+ return chains;
145
+ }
146
+
147
+ function cellResources(entries, chains) {
148
+ const byId = new Map(entries.map((entry) => [entry.resource_id, entry]));
149
+ const all = new Map(chains);
150
+ const chained = new Set([...chains.values()].flatMap((hops) => hops.map((hop) => hop.resource_id)));
151
+ for (const entry of entries) {
152
+ if (chained.has(entry.resource_id)) continue;
153
+ all.set(entry.resource_id, [{ resource_id: entry.resource_id, status: Array.isArray(entry.statuses) && entry.statuses.length ? entry.statuses[0] : null, mime_type: null }]);
154
+ }
155
+ return [...all.values()].map((hops) => {
156
+ const first = byId.get(hops[0].resource_id);
157
+ const final = byId.get(hops.at(-1).resource_id);
158
+ return {
159
+ resource_id: first.resource_id,
160
+ url: first.url,
161
+ type: final.resource_type,
162
+ transferred_bytes: final.transferred_bytes,
163
+ declared_bytes: final.declared_request_count > 0 ? final.declared_bytes : null,
164
+ measurement: measurementOf(final),
165
+ failed: final.failed_request_count > 0,
166
+ chain: hops.map((hop) => ({ url: byId.get(hop.resource_id).url, resource_id: hop.resource_id, status: hop.status })),
167
+ final_origin_equal: final.cross_origin_request_count === 0,
168
+ };
169
+ }).sort(resourceLedgerSort);
170
+ }
171
+
172
+ function cellVideos(capture, entries, documentOrigin) {
173
+ const matchable = entries.filter((entry) => Array.isArray(entry?.match_resource_ids));
174
+ return (Array.isArray(capture?.media) ? capture.media : [])
175
+ .filter((element) => isPlainObject(element) && element.tag_name === "video" && Array.isArray(element.source_references))
176
+ .map((element) => ({
177
+ element_index: element.element_index,
178
+ resource_ids: mediaFetchedResources(element, matchable).map((resource) => resource.resource_id).sort(),
179
+ declared_origin_equal: element.source_references.some((reference) => captureOrigin(reference?.url) === documentOrigin),
180
+ }));
181
+ }
182
+
183
+ const normalizedLoading = (value) => {
184
+ const token = typeof value === "string" ? value.trim().toLowerCase() : "";
185
+ return MEDIA_WEIGHT_VOCABULARY.loading.includes(token) ? token : "auto";
186
+ };
187
+
188
+ // The probe's <img> entries for the record. Geometry is kept only from a
189
+ // probe that completed; any other probe keeps each image's identity alone.
190
+ // An <img> binds by the identity it used: its currentSrc's resource_id (query
191
+ // included) when the ledger has that entry, else null (weight not_in_ledger).
192
+ // `vector` is whether its source is SVG: read from a data: URL's own type or
193
+ // from its request chain's final hop; null when neither can be read (a blob:
194
+ // or other non-http source, an http source with no ledger entry or no chain),
195
+ // which the rules never read as pass.
196
+ function cellImages(probe, chains, byId, documentUrl) {
197
+ const complete = probe.status === "complete";
198
+ return (Array.isArray(probe.images) ? probe.images : []).map((image) => {
199
+ const identity = ledgerResourceId(image?.current_src, documentUrl);
200
+ const resourceId = byId.has(identity) ? identity : null;
201
+ const finalHop = resourceId ? chains.get(resourceId)?.at(-1) : null;
202
+ let vector = null;
203
+ if (image?.current_src === "data:") vector = image?.svg_data === true;
204
+ else if (finalHop) vector = Boolean(finalHop.mime_type?.startsWith("image/svg+xml"));
205
+ return {
206
+ resource_id: resourceId,
207
+ element_path: image?.element_path,
208
+ complete: complete ? image?.complete : null,
209
+ natural: complete ? image?.natural : null,
210
+ rendered: complete ? image?.rendered : null,
211
+ object_fit: complete ? image?.object_fit : null,
212
+ loading: normalizedLoading(image?.loading),
213
+ hidden: complete ? image?.hidden : null,
214
+ vector,
215
+ };
216
+ });
217
+ }
218
+
219
+ // The probe status of a cell whose adapter returned no probe (the cell failed
220
+ // before it): the cell bound ended when the producer timed out, otherwise the
221
+ // document the probe needed was not there.
222
+ function missingProbe(capture) {
223
+ const timedOut = (Array.isArray(capture?.problems) ? capture.problems : []).some((problem) => problem?.code === "producer_timeout");
224
+ return { status: timedOut ? "probe_timeout" : "document_context_changed", dpr: null, images: [] };
225
+ }
226
+
227
+ export function buildMediaWeightCell({ route, viewport, capture, observation = null, probe = null }) {
228
+ const imageProbe = isPlainObject(probe) && MEDIA_WEIGHT_VOCABULARY.probe_status.includes(probe.status) ? probe : missingProbe(capture);
229
+ const documentUrl = observation?.finalDocumentUrl ?? capture?.document_response?.url ?? null;
230
+ const documentOrigin = capture?.document_response?.final_origin ?? null;
231
+ const entries = (Array.isArray(capture?.resource_ledger?.entries) ? capture.resource_ledger.entries : []).filter((entry) => isPlainObject(entry) && isNonEmptyString(entry.resource_id));
232
+ const byId = new Map(entries.map((entry) => [entry.resource_id, entry]));
233
+ const chains = requestChains(entries, byId, observation?.responses, documentUrl);
234
+ return {
235
+ route,
236
+ viewport,
237
+ dpr: typeof imageProbe.dpr === "number" && imageProbe.dpr > 0 ? imageProbe.dpr : null,
238
+ document_origin: documentOrigin,
239
+ page_load_integrity: capture?.integrity?.projection_fingerprint ?? null,
240
+ capture_status: capture?.measurement_status ?? "incomplete",
241
+ probe_status: imageProbe.status,
242
+ resources: cellResources(entries, chains),
243
+ images: cellImages(imageProbe, chains, byId, documentUrl),
244
+ videos: cellVideos(capture, entries, documentOrigin),
245
+ };
246
+ }
247
+
248
+ // The record for one Polish run. `subject` is the page_load subject;
249
+ // `uncapturedRoutes` are the public routes of spec pages the run skipped
250
+ // because they have no source mapping, and `uncapturedPageIds` the page ids
251
+ // of skipped pages whose public route cannot be resolved. Readers list both
252
+ // as page_not_captured.
253
+ export function buildMediaWeightRecord({ pageLoad, cells, uncapturedRoutes = [], uncapturedPageIds = [], measuredAt = new Date().toISOString() }) {
254
+ const record = {
255
+ schema_version: MEDIA_WEIGHT_SCHEMA,
256
+ performed_by: MEDIA_WEIGHT_PRODUCER,
257
+ measured_at: measuredAt,
258
+ subject: structuredClone(pageLoad.subject),
259
+ thresholds: { ...MEDIA_WEIGHT_THRESHOLDS },
260
+ cells,
261
+ uncaptured_routes: [...new Set(uncapturedRoutes)].sort(),
262
+ uncaptured_page_ids: [...new Set(uncapturedPageIds)].sort(),
263
+ };
264
+ return { ...record, integrity: mediaWeightIntegrity(record) };
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Rules (contract 1.3 Result rules)
269
+
270
+ const CAPTURE_INCOMPLETE = "capture_incomplete";
271
+ // Contract 1.0 Polish reader checks (:163) and 1.3 Observations (:936): a
272
+ // result that cannot be re-derived reads unexercised / evidence_not_reproducible.
273
+ const NOT_REPRODUCIBLE = "evidence_not_reproducible";
274
+
275
+ function invalid(message) {
276
+ throw new TypeError(`media_weight cell ${message}`);
277
+ }
278
+
279
+ // Scale s of the rendered box over the natural size, by object-fit; cover
280
+ // lets the cropped axis go uncounted.
281
+ function renderedScale(fit, [nw, nh], [rw, rh]) {
282
+ const contain = Math.min(rw / nw, rh / nh);
283
+ if (fit === "fill") return Math.sqrt((rw * rh) / (nw * nh));
284
+ if (fit === "contain") return contain;
285
+ if (fit === "scale-down") return Math.min(1, contain);
286
+ if (fit === "cover") return Math.max(rw / nw, rh / nh);
287
+ if (fit === "none") return 1;
288
+ return invalid(`image object_fit ${JSON.stringify(fit)} is not in the vocabulary`);
289
+ }
290
+
291
+ // Weight per resource in a complete cell, first match.
292
+ function weightRule(resource, { video, image, thresholds }) {
293
+ if (resource.failed) return ["unexercised", "not_loaded"];
294
+ if (!resource.final_origin_equal) return resource.measurement === "complete" ? ["pass", null] : ["unexercised", "transfer_partial"];
295
+ if (video) return ["warning", "video_from_document_origin"];
296
+ if (!image) return ["pass", null];
297
+ const over = resource.transferred_bytes > thresholds.image_bytes;
298
+ if (resource.measurement === "complete") return over ? ["warning", "image_over_threshold"] : ["pass", null];
299
+ if (resource.measurement === "lower_bound") {
300
+ if (over) return ["warning", "image_over_threshold"];
301
+ if (resource.declared_bytes !== null && resource.declared_bytes > thresholds.image_bytes) return ["review", "declared_over_threshold_unmeasured"];
302
+ }
303
+ return ["unexercised", "transfer_partial"];
304
+ }
305
+
306
+ // Oversizing per <img> in a cell whose probe completed, first match.
307
+ function oversizeRule(image, { requested, dpr, thresholds }) {
308
+ if (typeof image.complete !== "boolean" || typeof image.hidden !== "boolean" || !isPair(image.natural) || !isPair(image.rendered)) {
309
+ return invalid("image geometry is missing from a completed probe");
310
+ }
311
+ const [nw, nh] = image.natural;
312
+ const [rw, rh] = image.rendered;
313
+ if (image.loading === "lazy" && !requested && !image.complete) return { result: ["unexercised", "lazy_not_requested"] };
314
+ if (!image.complete || nw === 0 || nh === 0) return { result: ["unexercised", "not_loaded"] };
315
+ if (image.vector === true) return { result: ["unexercised", "vector_image"] };
316
+ // A source whose type the capture could not read (vector null) may be SVG.
317
+ if (image.vector === null) return { result: ["unexercised", "not_in_ledger"] };
318
+ if (image.hidden || rw === 0 || rh === 0) return { result: ["unexercised", "not_rendered"] };
319
+ const scale = renderedScale(image.object_fit, image.natural, image.rendered);
320
+ const factor = 1 / (scale * dpr);
321
+ const area = nw * nh;
322
+ const oversized = factor >= thresholds.oversize_factor && area >= thresholds.min_natural_area;
323
+ return { result: oversized ? ["warning", "image_oversized"] : ["pass", null], scale, factor, area };
324
+ }
325
+
326
+ // The probe statuses a cost cap sets: the image cap, the cell bound and the
327
+ // run budget.
328
+ const PROBE_COST_CAPS = Object.freeze(["image_cap_reached", "probe_timeout", "probe_budget_exhausted"]);
329
+
330
+ // The results of one media_weight cell, judged with `thresholds`: one
331
+ // media.weight result per resource, per unfetched <video> ("video:<index>")
332
+ // and per probed <img> whose currentSrc has no ledger entry
333
+ // ("img:<element_path>"); one media.oversize result per probed <img>
334
+ // ("<resource_id>:<element_path>"), or one keyed "cell" when the cell lists
335
+ // no <img>. Evaluation order: an incomplete capture makes every result
336
+ // capture_incomplete; a probe that did not complete makes every oversize
337
+ // result unexercised with its probe status. A cell a probe cost cap ended
338
+ // (the image cap, the cell bound or the run budget) adds the page_coverage
339
+ // member with that cap's code to every result, so none reads pass and none
340
+ // is accept-eligible.
341
+ export function evaluateMediaWeight(cell, thresholds = MEDIA_WEIGHT_THRESHOLDS) {
342
+ if (!isPlainObject(cell)) invalid("is not an object");
343
+ if (!isPlainObject(thresholds) || !["image_bytes", "oversize_factor", "min_natural_area"].every((field) => Number.isFinite(thresholds[field]) && thresholds[field] > 0)) {
344
+ invalid("thresholds are not the 1.3 thresholds");
345
+ }
346
+ const resources = Array.isArray(cell.resources) ? cell.resources : invalid("has no resources[]");
347
+ const images = Array.isArray(cell.images) ? cell.images : invalid("has no images[]");
348
+ const videos = Array.isArray(cell.videos) ? cell.videos : invalid("has no videos[]");
349
+ const incomplete = cell.capture_status !== "complete";
350
+ const probeComplete = cell.probe_status === "complete";
351
+ if (!incomplete && probeComplete && !(typeof cell.dpr === "number" && cell.dpr > 0)) invalid("has no observed device pixel ratio");
352
+ const cap = PROBE_COST_CAPS.includes(cell.probe_status) ? { key: "page_coverage", result: "unexercised", reason_code: cell.probe_status } : null;
353
+
354
+ const results = [];
355
+ const push = (check, key, [base, baseReason], state, observation) => {
356
+ let result = base;
357
+ let reasonCode = baseReason;
358
+ if (cap && result === "pass") [result, reasonCode] = ["unexercised", cap.reason_code];
359
+ const members = cap ? [{ ...cap }] : [];
360
+ results.push({
361
+ check,
362
+ subject: { check, page: cell.route, viewport: cell.viewport, key },
363
+ result,
364
+ reason_code: result === "pass" ? null : reasonCode,
365
+ members,
366
+ accept_eligible: result === "warning" && members.length === 0,
367
+ coverage: result === "unexercised" ? { observed: 0, expected: 1, limits: [reasonCode] } : { observed: 1, expected: 1, limits: [] },
368
+ state: { reason_code: result === "pass" ? null : reasonCode, ...state },
369
+ observation,
370
+ });
371
+ };
372
+
373
+ for (const resource of resources) {
374
+ if (!isPlainObject(resource) || !Array.isArray(resource.chain) || !resource.chain.length) invalid("resource has no chain");
375
+ }
376
+ // Every element binds to its resource through the one binder: a <video>
377
+ // by each ledger id it fetched, an <img> by its currentSrc's id. A
378
+ // resource that shares a hop with another resource is bound to no element
379
+ // alone: it and every element that names it read evidence_not_reproducible,
380
+ // never a result taken from one of the chains.
381
+ const bind = mediaChainBinder(resources);
382
+ const shared = new Set(resources.filter((resource) => resource.chain.some((hop) => bind(hop?.resource_id).status === "ambiguous")));
383
+ const videoBound = new Set();
384
+ const imageBound = new Set();
385
+ for (const video of videos) {
386
+ for (const id of Array.isArray(video?.resource_ids) ? video.resource_ids : []) {
387
+ const binding = bind(id);
388
+ if (binding.status === "bound") videoBound.add(binding.resource);
389
+ }
390
+ }
391
+ for (const image of images) {
392
+ const binding = bind(image?.resource_id);
393
+ if (binding.status === "bound") imageBound.add(binding.resource);
394
+ }
395
+
396
+ for (const resource of resources) {
397
+ const video = videoBound.has(resource);
398
+ const image = resource.type === "image" || imageBound.has(resource);
399
+ const finalUrl = resource.chain.at(-1)?.url ?? null;
400
+ let outcome;
401
+ if (incomplete) outcome = ["unexercised", CAPTURE_INCOMPLETE];
402
+ else if (shared.has(resource)) outcome = ["unexercised", NOT_REPRODUCIBLE];
403
+ else outcome = weightRule(resource, { video, image, thresholds });
404
+ push("media.weight", resource.resource_id, outcome, {
405
+ measurement: resource.measurement,
406
+ transferred_bytes: resource.transferred_bytes,
407
+ declared_bytes: resource.declared_bytes,
408
+ final_url: finalUrl,
409
+ final_origin_equal: resource.final_origin_equal,
410
+ chain: resource.chain,
411
+ }, {
412
+ origin: resource.final_origin_equal ? "same_origin" : "other_origin",
413
+ role: video ? "video" : image ? "image" : "other",
414
+ type: resource.type,
415
+ measurement: resource.measurement,
416
+ transferred_bytes: resource.transferred_bytes,
417
+ declared_bytes: resource.declared_bytes,
418
+ failed: resource.failed,
419
+ final_url: finalUrl,
420
+ final_origin_equal: resource.final_origin_equal,
421
+ chain: resource.chain,
422
+ });
423
+ }
424
+
425
+ for (const video of videos) {
426
+ if (Array.isArray(video?.resource_ids) && video.resource_ids.length) continue;
427
+ push("media.weight", `video:${video?.element_index}`, incomplete ? ["unexercised", CAPTURE_INCOMPLETE] : ["unexercised", "not_loaded"], {
428
+ element_index: video?.element_index,
429
+ declared_origin_equal: video?.declared_origin_equal,
430
+ }, { role: "video", element_index: video?.element_index, declared_origin_equal: video?.declared_origin_equal });
431
+ }
432
+
433
+ const unledgered = new Set();
434
+ for (const image of images) {
435
+ if (bind(image?.resource_id).status !== "absent") continue;
436
+ if (unledgered.has(image?.element_path)) continue;
437
+ unledgered.add(image?.element_path);
438
+ push("media.weight", `img:${image?.element_path}`, incomplete ? ["unexercised", CAPTURE_INCOMPLETE] : ["unexercised", "not_in_ledger"], {
439
+ element_path: image?.element_path,
440
+ }, { role: "image", element_path: image?.element_path, resource_id: image?.resource_id ?? null });
441
+ }
442
+
443
+ if (!images.length) {
444
+ const outcome = incomplete ? ["unexercised", CAPTURE_INCOMPLETE] : probeComplete ? ["pass", null] : ["unexercised", cell.probe_status];
445
+ push("media.oversize", "cell", outcome, { image_count: 0 }, { image_count: 0, probe_status: cell.probe_status });
446
+ }
447
+ for (const image of images) {
448
+ if (!isPlainObject(image) || !isNonEmptyString(image.element_path)) invalid("image has no element_path");
449
+ if (image.vector !== undefined && image.vector !== null && typeof image.vector !== "boolean") invalid("image vector flag is not a boolean or null");
450
+ const binding = bind(image.resource_id);
451
+ const matched = binding.status === "bound" ? binding.resource : null;
452
+ const finalUrl = matched ? matched.chain.at(-1)?.url ?? null : null;
453
+ let outcome;
454
+ let measured = {};
455
+ if (incomplete) outcome = ["unexercised", CAPTURE_INCOMPLETE];
456
+ else if (binding.status === "ambiguous" || shared.has(matched)) outcome = ["unexercised", NOT_REPRODUCIBLE];
457
+ else if (!probeComplete) outcome = ["unexercised", cell.probe_status];
458
+ else {
459
+ const judged = oversizeRule(image, { requested: Boolean(matched), dpr: cell.dpr, thresholds });
460
+ outcome = judged.result;
461
+ measured = { scale: judged.scale ?? null, factor: judged.factor ?? null, natural_area: judged.area ?? null };
462
+ }
463
+ push("media.oversize", `${image.resource_id ?? null}:${image.element_path}`, outcome, {
464
+ natural: image.natural ?? null,
465
+ rendered: image.rendered ?? null,
466
+ object_fit: image.object_fit ?? null,
467
+ dpr: cell.dpr ?? null,
468
+ final_url: finalUrl,
469
+ }, {
470
+ element_path: image.element_path,
471
+ natural: image.natural ?? null,
472
+ rendered: image.rendered ?? null,
473
+ object_fit: image.object_fit ?? null,
474
+ dpr: cell.dpr ?? null,
475
+ loading: image.loading,
476
+ complete: image.complete ?? null,
477
+ hidden: image.hidden ?? null,
478
+ vector: image.vector === undefined ? false : image.vector,
479
+ final_url: finalUrl,
480
+ probe_status: cell.probe_status,
481
+ ...measured,
482
+ });
483
+ }
484
+ return results;
485
+ }
486
+
487
+ // The 1.3 rules the QC reader re-derives with (src/qc-check-registry.mjs).
488
+ export const MEDIA_WEIGHT_QC_RULES = Object.freeze({
489
+ thresholds: MEDIA_WEIGHT_THRESHOLDS,
490
+ vocabulary: MEDIA_WEIGHT_VOCABULARY,
491
+ evaluate: evaluateMediaWeight,
492
+ });