@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.
- package/CHANGELOG.md +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
package/src/polish-capture.mjs
CHANGED
|
@@ -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,
|
package/src/polish-deadline.mjs
CHANGED
|
@@ -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
|
+
});
|