@nextcommerce/campaigns-os 1.60.0 → 1.61.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 +126 -0
- package/README.md +5 -5
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +768 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +28 -1
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaigns-os-build-flow.md +61 -2
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +40 -2
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -9
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
- package/skills.json +11 -11
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +2 -2
- package/src/cli.mjs +52 -13
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +8 -74
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +18 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/stage-record.mjs +45 -7
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
// Element parity: the package-owned stages.polish.evidence.visual_review.element_parity
|
|
2
|
+
// record, the rules that map a Comparison (src/parity/compare.mjs) to
|
|
3
|
+
// parity.element rows, and the reader that re-derives every row on read.
|
|
4
|
+
//
|
|
5
|
+
// The record holds refs, hashes, identities, side statuses and viewport
|
|
6
|
+
// geometry only. Page text, styles, colours, boxes and selector paths live in
|
|
7
|
+
// the local probe files (src/parity/snapshot.mjs), which the reader re-hashes;
|
|
8
|
+
// no cell status is stored. Rows never change a gate, `next` status or stage.
|
|
9
|
+
//
|
|
10
|
+
// The registry (src/qc-check-registry.mjs) loads ELEMENT_PARITY_QC_RULES with
|
|
11
|
+
// `plan` (planPolishCapture), `readFile` and `readFileForIdentity` (the probe
|
|
12
|
+
// file reads) attached, so this module needs neither the capture node nor the
|
|
13
|
+
// file module at load. Tests pass the same object with a comparator stand-in
|
|
14
|
+
// as `compare` (qcStandIns.polish["parity.element"]).
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { dirname, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { computeDesignSourcePackageMaterialFingerprint } from "../design-source-package.mjs";
|
|
19
|
+
import { canonicalJson } from "../polish-capture.mjs";
|
|
20
|
+
import { capturesIntegrity } from "../polish-captures-record.mjs";
|
|
21
|
+
import {
|
|
22
|
+
PARITY_MAX_ROUTES,
|
|
23
|
+
PARITY_PROBE_CELL_MS,
|
|
24
|
+
PARITY_PROBE_RUN_MS,
|
|
25
|
+
PARITY_PROBE_WALK_MS,
|
|
26
|
+
} from "../polish-deadline.mjs";
|
|
27
|
+
import { QC_PRODUCERS, QC_REASON, aggregateQcResults, buildQcResult, polishRecordIntegrity, qcResultId } from "../qc-results.mjs";
|
|
28
|
+
import { readRegularFileSync } from "../source-html-server.mjs";
|
|
29
|
+
import { PARITY_TOLERANCES, compareElementParity } from "./compare.mjs";
|
|
30
|
+
|
|
31
|
+
export const ELEMENT_PARITY_CHECK = "parity.element";
|
|
32
|
+
export const ELEMENT_PARITY_SCHEMA = "campaigns-os-polish-element-parity/v0";
|
|
33
|
+
export const ELEMENT_PARITY_PRODUCER = QC_PRODUCERS.polish;
|
|
34
|
+
export const PARITY_PROBE_VERSION = "parity-probe/v1";
|
|
35
|
+
export const PARITY_PROBE_FILE_SCHEMA = "campaigns-os-parity-probe/v0";
|
|
36
|
+
export const PARITY_SNAPSHOT_SCHEMA = "campaigns-os-parity-snapshot/v0";
|
|
37
|
+
export const PARITY_VIEWPORTS = Object.freeze(["desktop", "mobile"]);
|
|
38
|
+
export const PARITY_LIMITS = Object.freeze({
|
|
39
|
+
routes: PARITY_MAX_ROUTES, elements: 1500, text_chars: 500, page_text_chars: 262144, font_faces: 200,
|
|
40
|
+
walk_ms: PARITY_PROBE_WALK_MS, probe_cell_ms: PARITY_PROBE_CELL_MS, probe_run_ms: PARITY_PROBE_RUN_MS,
|
|
41
|
+
});
|
|
42
|
+
export const ELEMENT_PARITY_VOCABULARY = Object.freeze({
|
|
43
|
+
probe_status: Object.freeze(["measured", "timeout", "failed", "document_changed", "not_run", "run_budget_exhausted"]),
|
|
44
|
+
viewport: PARITY_VIEWPORTS,
|
|
45
|
+
category: Object.freeze(["copy", "typography", "color", "geometry"]),
|
|
46
|
+
comparison_status: Object.freeze(["compared", "viewport_mismatch", "design_elements_unavailable", "invalid_input"]),
|
|
47
|
+
});
|
|
48
|
+
// The ProbeOutput coverage conditions, in the order the comparison row names
|
|
49
|
+
// the first one present (contract §5.2 coverage table).
|
|
50
|
+
export const PARITY_COVERAGE_CONDITIONS = Object.freeze([
|
|
51
|
+
Object.freeze({ reason: "element_cap_reached", present: (c) => c?.elements_capped === true, cap: true }),
|
|
52
|
+
Object.freeze({ reason: "probe_time_cap_reached", present: (c) => c?.time_capped === true, cap: true }),
|
|
53
|
+
Object.freeze({ reason: "page_text_truncated", present: (c) => c?.page_text_truncated === true }),
|
|
54
|
+
Object.freeze({ reason: "closed_shadow_root_not_read", present: (c) => c?.closed_shadow_roots > 0 }),
|
|
55
|
+
Object.freeze({ reason: "frame_not_read", present: (c) => c?.frames_not_read > 0 }),
|
|
56
|
+
Object.freeze({ reason: "font_face_cap_reached", present: (c) => c?.font_faces_capped === true }),
|
|
57
|
+
]);
|
|
58
|
+
export const FONT_USAGE_LIMIT = "font_usage_not_observed";
|
|
59
|
+
|
|
60
|
+
const CATEGORY_KEYS = Object.freeze({ copy: "copy", typography: "type", color: "color", geometry: "geom" });
|
|
61
|
+
const C_CELL_CODES = Object.freeze(["navigation_failed", "http_error", "run_budget_exhausted", "producer_timeout", "browser_unavailable", "source_html_missing", "source_root_unavailable"]);
|
|
62
|
+
const COMPARATOR_STOPS = Object.freeze({ invalid_input: "probe_output_invalid", viewport_mismatch: "viewport_mismatch", design_elements_unavailable: "design_elements_unavailable" });
|
|
63
|
+
const FINGERPRINT = /^sha256:[a-f0-9]{64}$/;
|
|
64
|
+
const E = QC_REASON.EVIDENCE_NOT_REPRODUCIBLE;
|
|
65
|
+
|
|
66
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
67
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
|
|
68
|
+
const fingerprint = (value) => typeof value === "string" && FINGERPRINT.test(value);
|
|
69
|
+
const sameJson = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
|
|
70
|
+
const sha256Hex = (text) => createHash("sha256").update(text).digest("hex");
|
|
71
|
+
const canonicalTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
72
|
+
const integer = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
73
|
+
const collapse = (value) => String(value ?? "").replace(/\s+/g, " ").trim();
|
|
74
|
+
const firstCodePoints = (value, max) => [...value].slice(0, max).join("");
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Inputs shared by the producer and the reader
|
|
78
|
+
|
|
79
|
+
// Whether the packet maps at least one page to source HTML: the applicability
|
|
80
|
+
// of parity.element (contract §5.2 silence rule).
|
|
81
|
+
export function packetHasMappedPage(packet) {
|
|
82
|
+
return Array.isArray(packet?.source_html?.pages) && packet.source_html.pages.some((page) => isNonEmptyString(page?.path));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The Design Source Package the packet names, read packet-relative as doctor
|
|
86
|
+
// and C's capture read it. {status: "ok", value} or {status: "unavailable"}.
|
|
87
|
+
export function readDesignSourcePackage(packet, packetPath) {
|
|
88
|
+
const path = packet?.design_source_package?.path;
|
|
89
|
+
if (!isNonEmptyString(path) || !isNonEmptyString(packetPath)) return { status: "unavailable" };
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(readRegularFileSync(resolve(dirname(packetPath), path)).toString("utf8"));
|
|
92
|
+
return isPlainObject(value) ? { status: "ok", value } : { status: "unavailable" };
|
|
93
|
+
} catch {
|
|
94
|
+
return { status: "unavailable" };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Bind CampaignSpec page ids only; source page ids can name another page's surface.
|
|
99
|
+
export function dspSurfaceId(dsp, pageId) {
|
|
100
|
+
const surfaces = Array.isArray(dsp?.surface_identity) ? dsp.surface_identity : [];
|
|
101
|
+
const surface = surfaces.find((s) => isPlainObject(s) && isNonEmptyString(s.id)
|
|
102
|
+
&& s.mappings?.campaign_spec_page_id === pageId);
|
|
103
|
+
return surface ? surface.id : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const htmlFunnel = (dsp) => (Array.isArray(dsp?.contributions) ? dsp.contributions : []).find((c) => isPlainObject(c) && c.kind === "html_funnel") || null;
|
|
107
|
+
|
|
108
|
+
// The sha256 the DSP records for a surface's source-<surface>-html ref, or null.
|
|
109
|
+
export function dspRecordedSha(dsp, surfaceId) {
|
|
110
|
+
if (!surfaceId) return null;
|
|
111
|
+
const ref = (Array.isArray(htmlFunnel(dsp)?.source_refs) ? htmlFunnel(dsp).source_refs : [])
|
|
112
|
+
.find((r) => isPlainObject(r) && r.id === `source-${surfaceId}-html`);
|
|
113
|
+
return fingerprint(ref?.sha256) ? ref.sha256 : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Whether P1 recorded a design file for the surface (observation only).
|
|
117
|
+
export function dspDesignFileRecorded(dsp, surfaceId) {
|
|
118
|
+
if (!surfaceId) return false;
|
|
119
|
+
return (Array.isArray(dsp?.contributions) ? dsp.contributions : []).some((c) => isPlainObject(c)
|
|
120
|
+
&& c.provenance?.source_type === "design_file" && Array.isArray(c.mappings) && c.mappings.some((m) => m?.surface_id === surfaceId));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Producer
|
|
125
|
+
|
|
126
|
+
const cCell = (captures, side, route, viewport) => (Array.isArray(captures?.cells) ? captures.cells : [])
|
|
127
|
+
.filter((cell) => isPlainObject(cell) && cell.side === side && cell.route === route && cell.viewport === viewport);
|
|
128
|
+
|
|
129
|
+
// The record of one capture. `run` is the probe run (createParityProbeRun);
|
|
130
|
+
// `writeFile(value)` writes one probe file and returns {path, sha256}. The
|
|
131
|
+
// grid is the first PARITY_LIMITS.routes plan routes × desktop, mobile,
|
|
132
|
+
// route-major; later routes are uncaptured_routes. A side reads `measured`
|
|
133
|
+
// with its file ref only when its probe measured and its file was written.
|
|
134
|
+
export function buildElementParityRecord({ plan, captures, run, packet, packetPath, buildFingerprint, sourceFingerprint = null,
|
|
135
|
+
writeFile, measuredAt = new Date().toISOString() } = {}) {
|
|
136
|
+
const planned = Array.isArray(plan?.routes) ? plan.routes : [];
|
|
137
|
+
const routes = planned.slice(0, PARITY_LIMITS.routes);
|
|
138
|
+
const dsp = readDesignSourcePackage(packet, packetPath);
|
|
139
|
+
const dspValue = dsp.status === "ok" ? dsp.value : null;
|
|
140
|
+
const sourceFp = fingerprint(sourceFingerprint) ? sourceFingerprint : null;
|
|
141
|
+
const cells = routes.flatMap((route) => PARITY_VIEWPORTS.map((viewport) => {
|
|
142
|
+
const [build] = cCell(captures, "build", route.requested_route, viewport);
|
|
143
|
+
const [source] = cCell(captures, "source_html", route.requested_route, viewport);
|
|
144
|
+
const surfaceId = dspSurfaceId(dspValue, route.page_id);
|
|
145
|
+
const sourceIdentity = {
|
|
146
|
+
source_ref_id: surfaceId ? `source-${surfaceId}-html` : null,
|
|
147
|
+
recorded_sha256: dspRecordedSha(dspValue, surfaceId),
|
|
148
|
+
served_sha256: fingerprint(source?.source_sha256) ? source.source_sha256 : null,
|
|
149
|
+
};
|
|
150
|
+
const side = (name, envelope) => {
|
|
151
|
+
const result = run?.result?.(name, route.requested_route, viewport) ?? null;
|
|
152
|
+
if (!result) return { probe_status: run?.exhausted?.(name, route.requested_route, viewport) ? "run_budget_exhausted" : "not_run", ref: null };
|
|
153
|
+
const status = ["measured", "timeout", "failed", "document_changed"].includes(result.status) ? result.status : "failed";
|
|
154
|
+
if (status !== "measured") return { probe_status: status, ref: null };
|
|
155
|
+
try {
|
|
156
|
+
return { probe_status: "measured", ref: writeFile({ ...envelope, probe: result.value }) };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { probe_status: "failed", ref: null, ...(error?.message === "parity_ignore_invalid" ? { failure_reason: "parity_ignore_invalid" } : {}) };
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const identity = { probe_version: PARITY_PROBE_VERSION, page_id: route.page_id, route: route.requested_route, viewport };
|
|
162
|
+
const snapshot = side("source_html", { schema: PARITY_SNAPSHOT_SCHEMA, side: "source_html", ...identity, surface_id: surfaceId,
|
|
163
|
+
source_package_material_fingerprint: sourceFp, source_identity: sourceIdentity });
|
|
164
|
+
const probe = side("build", { schema: PARITY_PROBE_FILE_SCHEMA, side: "build", ...identity, build_fingerprint: buildFingerprint });
|
|
165
|
+
const fixed = viewport === "desktop" ? [1440, 1200] : [390, 844];
|
|
166
|
+
return {
|
|
167
|
+
page_id: route.page_id, surface_id: surfaceId, route: route.requested_route, viewport,
|
|
168
|
+
width: build?.width ?? fixed[0], height: build?.height ?? fixed[1], device_scale_factor: build?.device_scale_factor ?? 1,
|
|
169
|
+
source_html: { probe_status: snapshot.probe_status, snapshot_ref: snapshot.ref, source_identity: sourceIdentity,
|
|
170
|
+
...(snapshot.failure_reason ? { failure_reason: snapshot.failure_reason } : {}) },
|
|
171
|
+
build: { probe_status: probe.probe_status, probe_ref: probe.ref, ...(probe.failure_reason ? { failure_reason: probe.failure_reason } : {}) },
|
|
172
|
+
};
|
|
173
|
+
}));
|
|
174
|
+
const record = {
|
|
175
|
+
schema_version: ELEMENT_PARITY_SCHEMA,
|
|
176
|
+
performed_by: ELEMENT_PARITY_PRODUCER,
|
|
177
|
+
probe_version: PARITY_PROBE_VERSION,
|
|
178
|
+
subject: {
|
|
179
|
+
build_fingerprint: buildFingerprint,
|
|
180
|
+
source_package_material_fingerprint: sourceFp,
|
|
181
|
+
campaign_slug: packet?.campaign?.public_route_slug ?? null,
|
|
182
|
+
captures_integrity: captures?.integrity ?? null,
|
|
183
|
+
routes: routes.map((route) => route.requested_route),
|
|
184
|
+
viewports: [...PARITY_VIEWPORTS],
|
|
185
|
+
},
|
|
186
|
+
uncaptured_routes: planned.slice(PARITY_LIMITS.routes).map((route) => route.requested_route),
|
|
187
|
+
tolerances: { ...PARITY_TOLERANCES },
|
|
188
|
+
limits: { ...PARITY_LIMITS },
|
|
189
|
+
cells,
|
|
190
|
+
measured_at: new Date(measuredAt).toISOString(),
|
|
191
|
+
};
|
|
192
|
+
return { ...record, integrity: polishRecordIntegrity(record) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Rules: one cell's Comparison → rows
|
|
197
|
+
|
|
198
|
+
const subjectOf = (page, viewport, key) => ({ check: ELEMENT_PARITY_CHECK, page, viewport, key });
|
|
199
|
+
const textSha = (text) => `sha256:${sha256Hex(String(text))}`;
|
|
200
|
+
|
|
201
|
+
// The element key of a finding: el- and the first 12 hex of the sha256 of its
|
|
202
|
+
// canonical design identity.
|
|
203
|
+
export function parityElementKey(designIdentity) {
|
|
204
|
+
return `el-${sha256Hex(canonicalJson(designIdentity)).slice(0, 12)}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function comparisonShapeOk(comparison) {
|
|
208
|
+
if (!isPlainObject(comparison) || !ELEMENT_PARITY_VOCABULARY.comparison_status.includes(comparison.status)) return false;
|
|
209
|
+
if (comparison.status !== "compared") return true;
|
|
210
|
+
const counts = comparison.counts;
|
|
211
|
+
if (!isPlainObject(counts) || !["design", "matched", "unmatched_present", "unmatched_missing"].every((key) => integer(counts[key]))) return false;
|
|
212
|
+
if (!Array.isArray(comparison.findings)) return false;
|
|
213
|
+
const keys = new Set();
|
|
214
|
+
for (const finding of comparison.findings) {
|
|
215
|
+
if (!isPlainObject(finding) || !ELEMENT_PARITY_VOCABULARY.category.includes(finding.category) || !integer(finding.design)
|
|
216
|
+
|| !isPlainObject(finding.design_identity) || !Array.isArray(finding.members) || finding.members.length === 0
|
|
217
|
+
|| finding.members.some((m) => !isPlainObject(m) || !isNonEmptyString(m.property) || !["warning", "review"].includes(m.result) || !isNonEmptyString(m.reason_code))) return false;
|
|
218
|
+
const key = `${CATEGORY_KEYS[finding.category]}:${parityElementKey(finding.design_identity)}`;
|
|
219
|
+
if (keys.has(key)) return false;
|
|
220
|
+
keys.add(key);
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The coverage conditions present on either side of a compared cell:
|
|
226
|
+
// {reasons (first present first), members (per side, source_html first), caps}.
|
|
227
|
+
export function parityCoverageConditions(designCoverage, buildCoverage) {
|
|
228
|
+
const sides = [["source_html", designCoverage], ["build", buildCoverage]];
|
|
229
|
+
const reasons = PARITY_COVERAGE_CONDITIONS.filter((condition) => sides.some(([, coverage]) => condition.present(coverage))).map((c) => c.reason);
|
|
230
|
+
const members = sides.flatMap(([side, coverage]) => PARITY_COVERAGE_CONDITIONS.filter((condition) => condition.present(coverage))
|
|
231
|
+
.map((condition) => ({ key: `${side}:${condition.reason}`, result: "unexercised", reason_code: condition.reason })));
|
|
232
|
+
const caps = PARITY_COVERAGE_CONDITIONS.filter((condition) => condition.cap && reasons.includes(condition.reason)).map((c) => c.reason);
|
|
233
|
+
return { reasons, members, caps };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function elementState(category, members) {
|
|
237
|
+
if (category === "copy") {
|
|
238
|
+
const [copy] = members;
|
|
239
|
+
return { reason_code: copy.reason_code, design_text_sha256: textSha(copy.design ?? ""),
|
|
240
|
+
build_text_sha256: copy.build == null ? null : textSha(copy.build), similarity: copy.delta ?? null };
|
|
241
|
+
}
|
|
242
|
+
const values = members.map(({ property, design = null, build = null, delta = null }) => (category === "geometry"
|
|
243
|
+
? { property, design, build, delta } : { property, design, build }));
|
|
244
|
+
return { members: category === "typography" ? values.sort((a, b) => (a.property < b.property ? -1 : a.property > b.property ? 1 : 0)) : values };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// One cell's rows from its comparator output (contract §5.2 steps 8 to 10):
|
|
248
|
+
// a comparator stop reads `comparison` unexercised with no element rows; a
|
|
249
|
+
// compared cell reads `comparison` pass, or unexercised with the first
|
|
250
|
+
// coverage condition present, and one row per finding. `design` and `build`
|
|
251
|
+
// are the two ProbeOutputs (for labels and selector paths only). A malformed
|
|
252
|
+
// or throwing comparator reads comparator_failed.
|
|
253
|
+
export function evaluateElementParityCell({ page_id, viewport, design = null, build = null, comparison, compareError = false,
|
|
254
|
+
designFileRecorded = false, measured_at = null } = {}) {
|
|
255
|
+
const comparisonRow = (decision, observation = {}) => buildQcResult({ check: ELEMENT_PARITY_CHECK, leg: "polish",
|
|
256
|
+
subject: subjectOf(page_id, viewport, "comparison"), result: decision.result, reason_code: decision.reason_code ?? null,
|
|
257
|
+
members: decision.members ?? [], accept_eligible: false,
|
|
258
|
+
coverage: decision.coverage ?? { observed: 0, expected: null, limits: [FONT_USAGE_LIMIT, decision.reason_code] },
|
|
259
|
+
state: { reason_code: decision.reason_code ?? null, ...(decision.state ?? {}) }, observation, measured_at });
|
|
260
|
+
if (compareError || !comparisonShapeOk(comparison)) return [comparisonRow({ result: "unexercised", reason_code: "comparator_failed" })];
|
|
261
|
+
if (comparison.status !== "compared") return [comparisonRow({ result: "unexercised", reason_code: COMPARATOR_STOPS[comparison.status] })];
|
|
262
|
+
const conditions = parityCoverageConditions(design?.coverage, build?.coverage);
|
|
263
|
+
const counts = comparison.counts;
|
|
264
|
+
const coverage = { observed: counts.matched + counts.unmatched_present + counts.unmatched_missing, expected: counts.design,
|
|
265
|
+
limits: [FONT_USAGE_LIMIT, ...conditions.reasons] };
|
|
266
|
+
const decision = conditions.reasons.length
|
|
267
|
+
? { result: "unexercised", reason_code: conditions.reasons[0], members: conditions.members, coverage }
|
|
268
|
+
: { result: "pass", reason_code: null, members: [], coverage };
|
|
269
|
+
const rows = [comparisonRow({ ...decision, state: { counts } }, { basis: "source_html_export", design_file_recorded: designFileRecorded === true,
|
|
270
|
+
drift: isPlainObject(comparison.drift) ? comparison.drift : null, counts })];
|
|
271
|
+
const designElements = new Map((Array.isArray(design?.elements) ? design.elements : []).filter(isPlainObject).map((el) => [el.index, el]));
|
|
272
|
+
const buildElements = new Map((Array.isArray(build?.elements) ? build.elements : []).filter(isPlainObject).map((el) => [el.index, el]));
|
|
273
|
+
for (const finding of comparison.findings) {
|
|
274
|
+
const elementKey = parityElementKey(finding.design_identity);
|
|
275
|
+
const key = `${CATEGORY_KEYS[finding.category]}:${elementKey}`;
|
|
276
|
+
const members = finding.members.map((m) => ({ key: m.property, result: m.result, reason_code: m.reason_code }));
|
|
277
|
+
const caps = conditions.caps.map((reason) => ({ key: "page_coverage", result: "unexercised", reason_code: reason }));
|
|
278
|
+
const aggregated = aggregateQcResults([...members, ...caps]);
|
|
279
|
+
const designText = designElements.get(finding.design)?.text;
|
|
280
|
+
rows.push(buildQcResult({ check: ELEMENT_PARITY_CHECK, leg: "polish", subject: subjectOf(page_id, viewport, key),
|
|
281
|
+
result: aggregated.result, reason_code: aggregated.reason_code, members: aggregated.members,
|
|
282
|
+
coverage: finding.category === "typography" ? { observed: 1, expected: 1, limits: [FONT_USAGE_LIMIT] } : undefined,
|
|
283
|
+
state: elementState(finding.category, finding.members),
|
|
284
|
+
observation: {
|
|
285
|
+
element_key: elementKey,
|
|
286
|
+
design_selector_path: typeof finding.design_identity.selector_path === "string" ? finding.design_identity.selector_path : null,
|
|
287
|
+
build_selector_path: finding.build == null ? null : buildElements.get(finding.build)?.selector_path ?? null,
|
|
288
|
+
label: firstCodePoints(collapse(typeof designText === "string" ? designText : ""), 40),
|
|
289
|
+
basis: "source_html_export",
|
|
290
|
+
design_file_recorded: designFileRecorded === true,
|
|
291
|
+
members: finding.members.map(({ property, design: d = null, build: b = null, delta = null }) => (finding.category === "copy"
|
|
292
|
+
? { property, delta } : { property, design: d, build: b, delta })),
|
|
293
|
+
},
|
|
294
|
+
measured_at }));
|
|
295
|
+
}
|
|
296
|
+
return rows;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Reader (contract §5.2 reader checks and precedence)
|
|
301
|
+
|
|
302
|
+
const viewportOk = (value) => PARITY_VIEWPORTS.includes(value);
|
|
303
|
+
const cellIdentityOk = (cell) => isPlainObject(cell) && isNonEmptyString(cell.page_id) && viewportOk(cell.viewport);
|
|
304
|
+
const refOk = (ref) => isPlainObject(ref) && Object.keys(ref).length === 2 && typeof ref.path === "string" && fingerprint(ref.sha256)
|
|
305
|
+
&& ref.path === `.campaign-runtime/polish/parity/${ref.sha256.slice(7)}.json`;
|
|
306
|
+
const nullableFp = (value) => value === null || fingerprint(value);
|
|
307
|
+
|
|
308
|
+
// Record-level checks 1 to 5 (every row evidence_not_reproducible): the
|
|
309
|
+
// failing check's name, or null.
|
|
310
|
+
function recordProblem(record, { packet, campaignSlug, planned, captures, rules }) {
|
|
311
|
+
if (!isPlainObject(record) || record.schema_version !== ELEMENT_PARITY_SCHEMA || record.performed_by !== ELEMENT_PARITY_PRODUCER) return "record_shape_invalid";
|
|
312
|
+
if (record.integrity !== polishRecordIntegrity(record)) return "integrity_mismatch";
|
|
313
|
+
if (!canonicalTime(record.measured_at)) return "record_shape_invalid";
|
|
314
|
+
if (!sameJson(record.tolerances, rules.thresholds) || !sameJson(record.limits, PARITY_LIMITS)) return "limits_changed";
|
|
315
|
+
const subject = record.subject;
|
|
316
|
+
if (!isPlainObject(subject) || !sameJson(subject.viewports, PARITY_VIEWPORTS)) return "record_shape_invalid";
|
|
317
|
+
const slug = packet?.campaign?.public_route_slug;
|
|
318
|
+
if (!isNonEmptyString(slug) || subject.campaign_slug !== slug || (campaignSlug != null && subject.campaign_slug !== campaignSlug)) return "campaign_slug_mismatch";
|
|
319
|
+
if (!fingerprint(subject.build_fingerprint) || !nullableFp(subject.source_package_material_fingerprint) || !fingerprint(subject.captures_integrity)
|
|
320
|
+
|| typeof record.probe_version !== "string") return "record_shape_invalid";
|
|
321
|
+
const routes = subject.routes, uncaptured = record.uncaptured_routes;
|
|
322
|
+
if (!Array.isArray(routes) || !routes.every(isNonEmptyString) || !Array.isArray(uncaptured) || !uncaptured.every(isNonEmptyString)) return "record_shape_invalid";
|
|
323
|
+
if (!Array.isArray(record.cells)) return "record_shape_invalid";
|
|
324
|
+
for (const cell of record.cells) {
|
|
325
|
+
if (!cellIdentityOk(cell) || !isNonEmptyString(cell.route) || !(cell.surface_id === null || isNonEmptyString(cell.surface_id))
|
|
326
|
+
|| ![cell.width, cell.height, cell.device_scale_factor].every((n) => typeof n === "number" && Number.isFinite(n))
|
|
327
|
+
|| !isPlainObject(cell.source_html) || !isPlainObject(cell.build) || !isPlainObject(cell.source_html.source_identity)) return "record_shape_invalid";
|
|
328
|
+
const identity = cell.source_html.source_identity;
|
|
329
|
+
if (!(identity.source_ref_id === null || isNonEmptyString(identity.source_ref_id)) || !nullableFp(identity.recorded_sha256) || !nullableFp(identity.served_sha256)) return "record_shape_invalid";
|
|
330
|
+
for (const [side, refName] of [[cell.source_html, "snapshot_ref"], [cell.build, "probe_ref"]]) {
|
|
331
|
+
if (!ELEMENT_PARITY_VOCABULARY.probe_status.includes(side.probe_status)) return "vocabulary_invalid";
|
|
332
|
+
if (side.probe_status === "measured" ? !refOk(side[refName]) : side[refName] !== null) return "vocabulary_invalid";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Grid: disjoint, in plan order, inside the plan; at most 32 captured
|
|
336
|
+
// routes and uncaptured ones only beyond them; cells exactly routes ×
|
|
337
|
+
// viewports, route-major.
|
|
338
|
+
const order = new Map(planned.map((route, index) => [route.requested_route, index]));
|
|
339
|
+
const inPlanOrder = (list) => list.every((route, i) => order.has(route) && (i === 0 || order.get(list[i - 1]) < order.get(route)));
|
|
340
|
+
if (!inPlanOrder(routes) || !inPlanOrder(uncaptured) || routes.some((route) => uncaptured.includes(route))) return "grid_invalid";
|
|
341
|
+
if (routes.length > PARITY_LIMITS.routes || (uncaptured.length > 0 && routes.length !== PARITY_LIMITS.routes)) return "grid_invalid";
|
|
342
|
+
const expected = routes.flatMap((route) => PARITY_VIEWPORTS.map((viewport) => [route, viewport]));
|
|
343
|
+
if (record.cells.length !== expected.length || record.cells.some((cell, i) => cell.route !== expected[i][0] || cell.viewport !== expected[i][1]
|
|
344
|
+
|| cell.page_id !== planned[order.get(cell.route)].page_id)) return "grid_invalid";
|
|
345
|
+
// C binding.
|
|
346
|
+
if (!isPlainObject(captures) || captures.integrity !== capturesIntegrity(captures)) return "captures_unverified";
|
|
347
|
+
if (subject.captures_integrity !== captures.integrity) return "captures_integrity_mismatch";
|
|
348
|
+
for (const cell of record.cells) {
|
|
349
|
+
const builds = cCell(captures, "build", cell.route, cell.viewport), sources = cCell(captures, "source_html", cell.route, cell.viewport);
|
|
350
|
+
if (builds.length !== 1 || sources.length !== 1) return "captures_cell_missing";
|
|
351
|
+
if (cell.width !== builds[0].width || cell.height !== builds[0].height || cell.device_scale_factor !== builds[0].device_scale_factor) return "captures_geometry_mismatch";
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Every row of a record, re-derived on read. `rules` is ELEMENT_PARITY_QC_RULES
|
|
357
|
+
// as loaded (with plan, readFile, readFileForIdentity, compare). Never throws.
|
|
358
|
+
export function readElementParityRows(context = {}, rules = ELEMENT_PARITY_QC_RULES) {
|
|
359
|
+
const comparisonCache = new Map();
|
|
360
|
+
try {
|
|
361
|
+
return readRows(context, rules, comparisonCache);
|
|
362
|
+
} catch {
|
|
363
|
+
return refusedRecordRows(context, rules, E, "reader_failed", null, comparisonCache);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The fallback may revisit cells already compared before a reader failure.
|
|
368
|
+
// Keep successes and failures per cell for this read only.
|
|
369
|
+
function compareCell(cell, design, build, { rules, comparisonCache }) {
|
|
370
|
+
if (!comparisonCache.has(cell)) {
|
|
371
|
+
let comparison = null, compareError = false;
|
|
372
|
+
try { comparison = (rules.compare ?? compareElementParity)(design, build); } catch { compareError = true; }
|
|
373
|
+
comparisonCache.set(cell, { comparison, compareError });
|
|
374
|
+
}
|
|
375
|
+
return comparisonCache.get(cell);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function plannedRoutes(packet, rules) {
|
|
379
|
+
if (!packet || typeof rules?.plan !== "function") return null;
|
|
380
|
+
try {
|
|
381
|
+
const plan = rules.plan({ packet, baseUrl: "http://127.0.0.1" });
|
|
382
|
+
return Array.isArray(plan?.routes) ? plan.routes : null;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return error?.code === "no_capturable_routes" ? [] : null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Rows named only to keep their identities: a refused cell keeps the element
|
|
389
|
+
// rows its files name, so an accept lapses instead of orphaning.
|
|
390
|
+
function identityRows(cell, env) {
|
|
391
|
+
const { record, targetRepo, rules, measuredAt, qcAccepts } = env;
|
|
392
|
+
// Accepts supply identity only, never evidence or disposition. When a ref
|
|
393
|
+
// or entire cell is gone there may be no bytes from which to reconstruct a
|
|
394
|
+
// finding. Keep those named subjects on refusal so the accept can lapse.
|
|
395
|
+
const retained = (Array.isArray(qcAccepts) ? qcAccepts : []).filter((accept) => accept?.check === ELEMENT_PARITY_CHECK
|
|
396
|
+
&& accept.leg === "polish" && accept.subject?.check === ELEMENT_PARITY_CHECK
|
|
397
|
+
&& accept.subject.page === cell.page_id && accept.subject.viewport === cell.viewport
|
|
398
|
+
&& /^(copy|type|color|geom):el-[a-f0-9]{12}$/.test(accept.subject.key))
|
|
399
|
+
.map((accept) => buildQcResult({ check: ELEMENT_PARITY_CHECK, leg: "polish", subject: subjectOf(cell.page_id, cell.viewport, accept.subject.key),
|
|
400
|
+
result: "unexercised", reason_code: E, accept_eligible: false, state: { reason_code: E }, measured_at: measuredAt ?? null }));
|
|
401
|
+
const keep = (rows = []) => [...new Map([...retained, ...rows].map((row) => [row.id, row])).values()];
|
|
402
|
+
if (typeof rules?.readFileForIdentity !== "function") return retained;
|
|
403
|
+
const design = rules.readFileForIdentity(cell.source_html?.snapshot_ref, targetRepo)?.probe;
|
|
404
|
+
const build = rules.readFileForIdentity(cell.build?.probe_ref, targetRepo)?.probe;
|
|
405
|
+
if (!isPlainObject(design) || !isPlainObject(build)) return retained;
|
|
406
|
+
const { comparison, compareError } = compareCell(cell, design, build, env);
|
|
407
|
+
if (compareError || !comparisonShapeOk(comparison) || comparison.status !== "compared") return retained;
|
|
408
|
+
return keep(evaluateElementParityCell({ page_id: cell.page_id, viewport: cell.viewport, design, build, comparison, measured_at: measuredAt ?? record?.measured_at ?? null })
|
|
409
|
+
.filter((row) => row.subject.key !== "comparison"));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const refuseRow = (row, reason, stale) => (stale
|
|
413
|
+
? { ...row, result: "unexercised", reason_code: reason, accept_eligible: false,
|
|
414
|
+
coverage: { ...row.coverage, limits: [...(row.subject.key.startsWith("type:") ? [FONT_USAGE_LIMIT] : []), reason] } }
|
|
415
|
+
: { ...row, result: "unexercised", reason_code: reason, accept_eligible: false, members: [], observation: {},
|
|
416
|
+
coverage: { observed: 0, expected: null, limits: [...(row.subject.key.startsWith("type:") ? [FONT_USAGE_LIMIT] : []), reason] } });
|
|
417
|
+
|
|
418
|
+
function refusedCellRows(cell, reason, env, { members = [], result = "unexercised", elements = true } = {}) {
|
|
419
|
+
const measuredAt = canonicalTime(env.record?.measured_at) ? env.record.measured_at : null;
|
|
420
|
+
const comparison = buildQcResult({ check: ELEMENT_PARITY_CHECK, leg: "polish", subject: subjectOf(cell.page_id, cell.viewport, "comparison"),
|
|
421
|
+
result, reason_code: reason, members, accept_eligible: false, state: { reason_code: reason },
|
|
422
|
+
coverage: { observed: 0, expected: null, limits: [FONT_USAGE_LIMIT, reason] }, measured_at: measuredAt });
|
|
423
|
+
if (!elements) return [comparison];
|
|
424
|
+
return [comparison, ...identityRows(cell, env).map((row) => refuseRow(row, reason, reason === QC_REASON.STALE_BINDING))];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Every row of a record that failed a record-level check: the comparison row
|
|
428
|
+
// (with the failure as its member for evidence_not_reproducible) and the
|
|
429
|
+
// element rows its files/retained accepts name. The expected grid comes from
|
|
430
|
+
// the packet plan and record subject even when only some cells survive.
|
|
431
|
+
function refusedRecordRows(context, rules, reason, detail = null, planned = null, comparisonCache) {
|
|
432
|
+
const env = { record: context.record, targetRepo: context.targetRepo, rules, qcAccepts: context.qcAccepts, comparisonCache };
|
|
433
|
+
const members = reason === E && detail ? [{ key: "evidence", result: "unexercised", reason_code: detail }] : [];
|
|
434
|
+
const cells = (Array.isArray(context.record?.cells) ? context.record.cells : []).filter(cellIdentityOk);
|
|
435
|
+
const grid = (planned ?? plannedRoutes(context.packet, rules) ?? []).flatMap((route) => PARITY_VIEWPORTS.map((viewport) => ({ page_id: route.page_id, route: route.requested_route, viewport })));
|
|
436
|
+
const targets = [...new Map([...grid, ...cells].map((cell) => [JSON.stringify([cell.page_id, cell.viewport]), cell])).values()];
|
|
437
|
+
const rows = targets.flatMap((cell) => refusedCellRows(cell, reason, env, { members }));
|
|
438
|
+
// Preserve page_not_captured identities as well when the record names an
|
|
439
|
+
// uncaptured part of the plan; all rows share the record-level refusal.
|
|
440
|
+
for (const cell of grid) if (Array.isArray(context.record?.subject?.routes) && !context.record.subject.routes.includes(cell.route)) {
|
|
441
|
+
rows.push(buildQcResult({ check: ELEMENT_PARITY_CHECK, leg: "polish", subject: subjectOf(cell.page_id, cell.viewport, "cell"),
|
|
442
|
+
result: "unexercised", reason_code: reason, members, state: { reason_code: reason }, accept_eligible: false,
|
|
443
|
+
coverage: { observed: 0, expected: null, limits: [reason] }, measured_at: null }));
|
|
444
|
+
}
|
|
445
|
+
if (!rows.length) {
|
|
446
|
+
rows.push(buildQcResult({ check: ELEMENT_PARITY_CHECK, leg: "polish", subject: { check: ELEMENT_PARITY_CHECK, page: null, key: "element_parity" },
|
|
447
|
+
result: "unexercised", reason_code: reason, members, state: { reason_code: reason }, measured_at: null,
|
|
448
|
+
coverage: { observed: 0, expected: null, limits: [FONT_USAGE_LIMIT, reason] } }));
|
|
449
|
+
}
|
|
450
|
+
return [...new Map(rows.map((row) => [row.id, row])).values()];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function readRows(context, rules, comparisonCache) {
|
|
454
|
+
const { record, captures, packet, packetPath, currentBuild, currentSource, buildOutput, campaignSlug, targetRepo } = context;
|
|
455
|
+
const planned = plannedRoutes(packet, rules);
|
|
456
|
+
const refuseRecord = (reason, detail = null) => refusedRecordRows(context, rules, reason, detail, planned, comparisonCache);
|
|
457
|
+
if (!packet) return refuseRecord(E, "packet_unavailable");
|
|
458
|
+
if (!planned) return refuseRecord(E, "packet_plan_invalid");
|
|
459
|
+
if (!isPlainObject(rules) || typeof rules.readFile !== "function" || typeof rules.evaluate !== "function") {
|
|
460
|
+
return refuseRecord(E, "rules_unavailable");
|
|
461
|
+
}
|
|
462
|
+
const problem = recordProblem(record, { packet, campaignSlug, planned, captures, rules });
|
|
463
|
+
if (problem) return refuseRecord(E, problem);
|
|
464
|
+
if (record.probe_version !== PARITY_PROBE_VERSION) return refuseRecord("not_captured_by_this_version");
|
|
465
|
+
const measuredAt = record.measured_at;
|
|
466
|
+
const env = { record, targetRepo, rules, measuredAt, qcAccepts: context.qcAccepts, comparisonCache };
|
|
467
|
+
const stale = record.subject.build_fingerprint !== currentBuild
|
|
468
|
+
|| record.subject.source_package_material_fingerprint !== (currentSource || null)
|
|
469
|
+
|| (buildOutput !== undefined && buildOutput?.status !== "pass");
|
|
470
|
+
if (stale) return refuseRecord(QC_REASON.STALE_BINDING);
|
|
471
|
+
|
|
472
|
+
const rows = [];
|
|
473
|
+
const dsp = readDesignSourcePackage(packet, packetPath);
|
|
474
|
+
let dspReason = null;
|
|
475
|
+
if (dsp.status !== "ok") dspReason = "source_package_unavailable";
|
|
476
|
+
else {
|
|
477
|
+
try { if (computeDesignSourcePackageMaterialFingerprint(dsp.value) !== currentSource) dspReason = "source_package_stale"; }
|
|
478
|
+
catch { dspReason = "source_package_stale"; }
|
|
479
|
+
}
|
|
480
|
+
// Planned routes this record did not capture (uncaptured_routes, or added
|
|
481
|
+
// to the plan since): one cell row per viewport.
|
|
482
|
+
for (const route of planned) {
|
|
483
|
+
if (record.subject.routes.includes(route.requested_route)) continue;
|
|
484
|
+
for (const viewport of PARITY_VIEWPORTS) {
|
|
485
|
+
const subject = subjectOf(route.page_id, viewport, "cell");
|
|
486
|
+
rows.push(buildQcResult({ check: ELEMENT_PARITY_CHECK, leg: "polish", subject, result: "unexercised", reason_code: "page_not_captured",
|
|
487
|
+
state: { route: route.requested_route }, observation: { route: route.requested_route }, accept_eligible: false,
|
|
488
|
+
coverage: { observed: 0, expected: 1, limits: ["page_not_captured"] }, measured_at: measuredAt }));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
for (const cell of record.cells) {
|
|
492
|
+
if (dspReason) { rows.push(...refusedCellRows(cell, dspReason, env)); continue; }
|
|
493
|
+
rows.push(...cellRows(cell, { ...env, captures, dsp: dsp.value }));
|
|
494
|
+
}
|
|
495
|
+
return [...new Map(rows.map((row) => [row.id, row])).values()];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Steps 6 to 10 for one cell of a record that passed every record-level check.
|
|
499
|
+
function cellRows(cell, env) {
|
|
500
|
+
const { record, captures, dsp, targetRepo, rules } = env;
|
|
501
|
+
const refuse = (reason, options) => refusedCellRows(cell, reason, env, options);
|
|
502
|
+
const sides = [["source_html", cell.source_html.snapshot_ref], ["build", cell.build.probe_ref]];
|
|
503
|
+
// a, b: each referenced file, source side first.
|
|
504
|
+
const files = {};
|
|
505
|
+
for (const [side, ref] of sides) if (ref) files[side] = rules.readFile(ref, targetRepo);
|
|
506
|
+
if (sides.some(([side]) => files[side]?.status === "absent")) return refuse("probe_file_absent");
|
|
507
|
+
if (sides.some(([side]) => files[side] && files[side].status !== "ok")) return refuse(E, { members: [{ key: "evidence", result: "unexercised", reason_code: "probe_file_invalid" }] });
|
|
508
|
+
const snapshot = files.source_html?.value, probe = files.build?.value;
|
|
509
|
+
// c: envelopes.
|
|
510
|
+
const envelopeOk = (value, side, schema) => isPlainObject(value) && value.schema === schema && value.side === side
|
|
511
|
+
&& value.probe_version === PARITY_PROBE_VERSION && value.page_id === cell.page_id && value.route === cell.route && value.viewport === cell.viewport;
|
|
512
|
+
if ((snapshot !== undefined && (!envelopeOk(snapshot, "source_html", PARITY_SNAPSHOT_SCHEMA) || snapshot.surface_id !== cell.surface_id))
|
|
513
|
+
|| (probe !== undefined && !envelopeOk(probe, "build", PARITY_PROBE_FILE_SCHEMA))) {
|
|
514
|
+
return refuse(E, { members: [{ key: "evidence", result: "unexercised", reason_code: "probe_envelope_mismatch" }] });
|
|
515
|
+
}
|
|
516
|
+
// d: authoritative identities.
|
|
517
|
+
const identity = cell.source_html.source_identity;
|
|
518
|
+
const surfaceId = dspSurfaceId(dsp, cell.page_id);
|
|
519
|
+
const [cSource] = cCell(captures, "source_html", cell.route, cell.viewport);
|
|
520
|
+
const [cBuild] = cCell(captures, "build", cell.route, cell.viewport);
|
|
521
|
+
const served = fingerprint(cSource.source_sha256) ? cSource.source_sha256 : null;
|
|
522
|
+
if ((probe !== undefined && probe.build_fingerprint !== record.subject.build_fingerprint)
|
|
523
|
+
|| (snapshot !== undefined && (snapshot.source_package_material_fingerprint !== record.subject.source_package_material_fingerprint
|
|
524
|
+
|| !sameJson(snapshot.source_identity, identity)))
|
|
525
|
+
|| cell.surface_id !== surfaceId || identity.source_ref_id !== (surfaceId ? `source-${surfaceId}-html` : null)
|
|
526
|
+
|| identity.recorded_sha256 !== dspRecordedSha(dsp, surfaceId) || identity.served_sha256 !== served) {
|
|
527
|
+
return refuse(E, { members: [{ key: "evidence", result: "unexercised", reason_code: "source_identity_unbound" }] });
|
|
528
|
+
}
|
|
529
|
+
// Step 7: the closed translation, source side before build side.
|
|
530
|
+
const cellsBySide = [["source_html", cSource, cell.source_html], ["build", cBuild, cell.build]];
|
|
531
|
+
for (const [, cCellValue] of cellsBySide) {
|
|
532
|
+
if (cCellValue.status !== "rendered") return refuse(C_CELL_CODES.includes(cCellValue.status) ? cCellValue.status : "navigation_failed");
|
|
533
|
+
}
|
|
534
|
+
const recorded = dspRecordedSha(dsp, surfaceId);
|
|
535
|
+
if (recorded === null) return refuse("source_identity_unrecorded");
|
|
536
|
+
if (served !== recorded) return refuse("source_identity_mismatch", { result: "review", elements: false });
|
|
537
|
+
const readiness = cellsBySide.flatMap(([side, c]) => [
|
|
538
|
+
...(c.readiness?.fonts?.status !== "settled" ? [{ key: `${side}:fonts`, result: "unexercised", reason_code: "readiness_timeout" }] : []),
|
|
539
|
+
...(!["settled", "not_present"].includes(c.readiness?.await?.status) ? [{ key: `${side}:await`, result: "unexercised", reason_code: "readiness_timeout" }] : []),
|
|
540
|
+
]);
|
|
541
|
+
if (readiness.length) return refuse("readiness_incomplete", { members: readiness });
|
|
542
|
+
for (const [status, reason] of [["run_budget_exhausted", "run_budget_exhausted"], ["not_run", "probe_not_run"]]) {
|
|
543
|
+
if (cellsBySide.some(([, , p]) => p.probe_status === status)) return refuse(reason);
|
|
544
|
+
}
|
|
545
|
+
for (const [, , p] of cellsBySide) {
|
|
546
|
+
if (p.probe_status === "measured") continue;
|
|
547
|
+
return refuse(p.probe_status === "timeout" ? "probe_timeout" : p.probe_status === "document_changed" ? "document_changed" : "probe_failed");
|
|
548
|
+
}
|
|
549
|
+
// Steps 8 to 10.
|
|
550
|
+
const design = snapshot.probe, build = probe.probe;
|
|
551
|
+
const { comparison, compareError } = compareCell(cell, design, build, env);
|
|
552
|
+
const designOk = isPlainObject(design), buildOk = isPlainObject(build);
|
|
553
|
+
return rules.evaluate({ page_id: cell.page_id, viewport: cell.viewport, design: designOk ? design : null, build: buildOk ? build : null,
|
|
554
|
+
comparison, compareError, designFileRecorded: dspDesignFileRecorded(dsp, surfaceId), measured_at: record.measured_at });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export const ELEMENT_PARITY_QC_RULES = Object.freeze({
|
|
558
|
+
thresholds: PARITY_TOLERANCES,
|
|
559
|
+
vocabulary: ELEMENT_PARITY_VOCABULARY,
|
|
560
|
+
limits: PARITY_LIMITS,
|
|
561
|
+
viewports: PARITY_VIEWPORTS,
|
|
562
|
+
schema_version: ELEMENT_PARITY_SCHEMA,
|
|
563
|
+
probe_version: PARITY_PROBE_VERSION,
|
|
564
|
+
evaluate: evaluateElementParityCell,
|
|
565
|
+
compare: compareElementParity,
|
|
566
|
+
read: readElementParityRows,
|
|
567
|
+
applies: packetHasMappedPage,
|
|
568
|
+
rowKeys: (rows) => (Array.isArray(rows) ? rows.map((row) => qcResultId(row?.subject)) : []),
|
|
569
|
+
});
|