@nextcommerce/campaigns-os 1.60.0 → 1.62.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/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- 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 +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- 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 +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- 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/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -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 +5 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -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 +189 -10
- 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-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/qc-results.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
|
|
|
21
21
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
22
22
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
23
23
|
|
|
24
|
-
import { campaignSidecarPaths } from "./campaign-workspace.mjs";
|
|
24
|
+
import { campaignSidecarPaths, packetRuntimeRelPath } from "./campaign-workspace.mjs";
|
|
25
25
|
import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
|
|
26
26
|
import { contrastToolkit } from "./contrast.mjs";
|
|
27
27
|
import { sameFile } from "./fs-identity.mjs";
|
|
@@ -30,6 +30,8 @@ import { currentBuildFingerprint, currentSourcePackageMaterialFingerprint } from
|
|
|
30
30
|
import { sidecarPathForPacket } from "./qa-sidecar.mjs";
|
|
31
31
|
import { QA_ASSERTION_FAMILY_VOCABULARY, QA_SCHEMA_VERSION, SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
32
32
|
import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg, qcChecksForRecord } from "./qc-check-registry.mjs";
|
|
33
|
+
import { readPngStructure } from "./png-header.mjs";
|
|
34
|
+
import { readRegularFileSync } from "./source-html-server.mjs";
|
|
33
35
|
import { STORE_PAGE_MATCHERS } from "./spec-derive-store.mjs";
|
|
34
36
|
|
|
35
37
|
export const QC_RESULT_SCHEMA = "campaigns-os-qc-result/v0";
|
|
@@ -297,10 +299,14 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
|
|
|
297
299
|
const stats = statSync(real);
|
|
298
300
|
if (!stats.isFile()) return null;
|
|
299
301
|
if (!inside(realpathSync(outputDir), real)) return null;
|
|
302
|
+
// This funnel's default report and every other funnel's project-level
|
|
303
|
+
// one (#719): neither is a verdict.
|
|
304
|
+
const packet = isNonEmptyString(packetPath) ? readPacketForGuard(packetPath) : null;
|
|
300
305
|
const forbidden = [
|
|
301
306
|
reportPath,
|
|
302
307
|
campaignSidecarPaths(targetRepo).reportPath,
|
|
303
|
-
|
|
308
|
+
packet ? campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).reportPath : null,
|
|
309
|
+
isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath, packet) : null,
|
|
304
310
|
...outputs.filter((path) => path !== candidates[0]),
|
|
305
311
|
].filter(isNonEmptyString);
|
|
306
312
|
for (const path of forbidden) {
|
|
@@ -315,6 +321,15 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
|
|
|
315
321
|
}
|
|
316
322
|
}
|
|
317
323
|
|
|
324
|
+
function readPacketForGuard(packetPath) {
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(readFileSync(packetPath, "utf8"));
|
|
327
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
318
333
|
// `qaCurrency` is QA's read-time input currency
|
|
319
334
|
// (derived.input_currency.stages.qa). While QA is owed again or its inputs
|
|
320
335
|
// cannot be confirmed, every row that reproduces reads stale_binding, as a
|
|
@@ -831,6 +846,10 @@ const PAGE_NOT_CAPTURED = "page_not_captured";
|
|
|
831
846
|
// output under _site/<slug>/ is the one `record build` recorded. Any other
|
|
832
847
|
// value, null included, is drift. A caller that passes no value (undefined)
|
|
833
848
|
// reads without the output check.
|
|
849
|
+
// A capture file that is there but cannot be read as a regular file names
|
|
850
|
+
// why; only a missing file is capture_file_absent.
|
|
851
|
+
const captureReadFailure = (code) => code === "too_large" ? "capture_file_too_large"
|
|
852
|
+
: ["ELOOP", "EISDIR", "ENOTDIR", "not_regular_file", "outside_root"].includes(code) ? "capture_file_not_regular" : "capture_file_unreadable";
|
|
834
853
|
const outputDrifted = (buildOutput) => buildOutput !== undefined && buildOutput?.status !== "pass";
|
|
835
854
|
|
|
836
855
|
export function readMediaWeight({ record, pageLoad, currentBuild = null, buildOutput = undefined, qcStandIns = null, rederivers = null } = {}) {
|
|
@@ -1032,30 +1051,46 @@ function readabilityCropResolves(ref, targetRepo) {
|
|
|
1032
1051
|
// the record lacks, or one over the route cap, reads page_not_captured;
|
|
1033
1052
|
// a recorded route outside it, or different uncaptured routes or
|
|
1034
1053
|
// enumeration cap, → evidence_not_reproducible;
|
|
1035
|
-
// 4. the rows READABILITY_QC_RULES.evaluate derives from each cell
|
|
1054
|
+
// 4. the rows READABILITY_QC_RULES.evaluate derives from each cell, and,
|
|
1055
|
+
// for a record that stores layout_conventions, the layout.conventions
|
|
1056
|
+
// rows LAYOUT_QC_RULES.evaluate derives from it.
|
|
1036
1057
|
// `campaignSlug` is the packet's slug; a missing one never matches.
|
|
1037
|
-
export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput =
|
|
1058
|
+
export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = undefined, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
|
|
1038
1059
|
if (record === undefined || record === null) return [];
|
|
1039
|
-
const [check] = POLISH_RECORDS.readability.checks;
|
|
1060
|
+
const [check, layoutCheck = null] = POLISH_RECORDS.readability.checks;
|
|
1040
1061
|
const measuredAt = canonicalTime(record?.measured_at) ? record.measured_at : null;
|
|
1041
1062
|
const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
|
|
1042
|
-
const subjectOf = (page, viewport, key) => ({ check, page, viewport, key });
|
|
1043
|
-
const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check }, reasonCode, { leg: "polish", measuredAt });
|
|
1063
|
+
const subjectOf = (page, viewport, key, of = check) => ({ check: of, page, viewport, key });
|
|
1064
|
+
const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check: subject.check }, reasonCode, { leg: "polish", measuredAt });
|
|
1044
1065
|
const unique = (rows) => [...new Map(rows.map((row) => [row.id, row])).values()];
|
|
1045
1066
|
let rules = null;
|
|
1067
|
+
// The layout check reads only a record that stores the conventions it was
|
|
1068
|
+
// measured with; its rules load on their own.
|
|
1069
|
+
const layoutRecorded = isPlainObject(record) && Object.hasOwn(record, "layout_conventions") && isNonEmptyString(layoutCheck);
|
|
1070
|
+
const layoutResolved = layoutRecorded ? resolvePolishRules({ check: layoutCheck, qcStandIns, rederivers }) : { status: "missing" };
|
|
1071
|
+
const layoutRules = layoutResolved.status === "loaded" ? layoutResolved.rules : null;
|
|
1072
|
+
const layoutRead = layoutRecorded && layoutResolved.status !== "missing";
|
|
1073
|
+
const namedKeys = (rowKeys, cell) => {
|
|
1074
|
+
try {
|
|
1075
|
+
const named = typeof rowKeys === "function" ? rowKeys(cell) : [];
|
|
1076
|
+
return Array.isArray(named) ? named.filter(isNonEmptyString) : [];
|
|
1077
|
+
} catch {
|
|
1078
|
+
return [];
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
// A failed cell's layout rows: one per row the layout rules name for the
|
|
1082
|
+
// cell (its `cell` row among them; none at a viewport no convention
|
|
1083
|
+
// applies to), or its `cell` row when the rules did not load.
|
|
1084
|
+
const failLayout = (cell) => (layoutRead
|
|
1085
|
+
? [...new Set(layoutRules ? namedKeys(layoutRules.rowKeys, cell) : ["cell"])].map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key, layoutCheck), E))
|
|
1086
|
+
: []);
|
|
1046
1087
|
// A failed cell: one result per row the cell names and its `cell` row, so
|
|
1047
1088
|
// a failed cell is never silent and an accept of one of its rows lapses
|
|
1048
1089
|
// rather than orphans. The keys come from rules.rowKeys, which reads
|
|
1049
1090
|
// neither the record's thresholds nor any field the checks reject.
|
|
1050
1091
|
const failCell = (cell) => {
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
named = typeof rules?.rowKeys === "function" ? rules.rowKeys(cell) : [];
|
|
1054
|
-
} catch {
|
|
1055
|
-
named = [];
|
|
1056
|
-
}
|
|
1057
|
-
const keys = [...new Set([...(Array.isArray(named) ? named.filter(isNonEmptyString) : []), "cell"])];
|
|
1058
|
-
return keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E));
|
|
1092
|
+
const keys = [...new Set([...namedKeys(rules?.rowKeys, cell), "cell"])];
|
|
1093
|
+
return [...keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E)), ...failLayout(cell)];
|
|
1059
1094
|
};
|
|
1060
1095
|
// A record-level failure: every listed cell fails; every route the subject
|
|
1061
1096
|
// or a cell names, at every recorded or fixed viewport, that no cell lists
|
|
@@ -1118,7 +1153,8 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1118
1153
|
|| typeof record.route_enumeration_capped !== "boolean"
|
|
1119
1154
|
|| !Array.isArray(record.cells)
|
|
1120
1155
|
|| cells.length !== record.cells.length
|
|
1121
|
-
|| !cells.every((cell) => rules.cellShapeOk(cell))
|
|
1156
|
+
|| !cells.every((cell) => rules.cellShapeOk(cell))
|
|
1157
|
+
|| (layoutRecorded && layoutRules && !sameJson(record.layout_conventions, layoutRules.thresholds))) return failAll();
|
|
1122
1158
|
// The cell set is exactly routes × viewports: none missing, none added,
|
|
1123
1159
|
// none twice, every cell's route and viewport in the subject.
|
|
1124
1160
|
const gridKeys = declaredGrid(subject).map(({ route, viewport }) => cellKey(route, viewport));
|
|
@@ -1145,7 +1181,9 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1145
1181
|
const bound = isNonEmptyString(currentBuild)
|
|
1146
1182
|
&& subject.build_fingerprint === currentBuild
|
|
1147
1183
|
&& subject.source_package_material_fingerprint === (isNonEmptyString(currentSource) ? currentSource : null)
|
|
1148
|
-
|
|
1184
|
+
// As in the captures reader: no buildOutput (undefined) reads without the
|
|
1185
|
+
// output check; null is drift.
|
|
1186
|
+
&& !outputDrifted(buildOutput);
|
|
1149
1187
|
|
|
1150
1188
|
// 3. The recomputed route set, read only when the output is the build's.
|
|
1151
1189
|
const results = [];
|
|
@@ -1176,22 +1214,37 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1176
1214
|
}
|
|
1177
1215
|
const derived = evaluate(cell, cropAvailable);
|
|
1178
1216
|
// Every derived subject is the cell's own page and viewport, each key once.
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
&&
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1217
|
+
const derivedOk = (items, of) => {
|
|
1218
|
+
const keys = Array.isArray(items) ? items.map((item) => item?.subject?.key) : [];
|
|
1219
|
+
return Array.isArray(items) && new Set(keys).size === keys.length && items.every((item) => item?.check === of
|
|
1220
|
+
&& validDerived(item, of)
|
|
1221
|
+
&& hasExactly(item.subject, ["check", "page", "viewport", "key"])
|
|
1222
|
+
&& item.subject.page === cell.route
|
|
1223
|
+
&& item.subject.viewport === cell.viewport
|
|
1224
|
+
&& isNonEmptyString(item.subject.key));
|
|
1225
|
+
};
|
|
1226
|
+
if (!derivedOk(derived, check)) {
|
|
1186
1227
|
results.push(...failCell(cell));
|
|
1187
1228
|
continue;
|
|
1188
1229
|
}
|
|
1189
|
-
|
|
1230
|
+
let layoutDerived = [];
|
|
1231
|
+
if (layoutRead) {
|
|
1232
|
+
try {
|
|
1233
|
+
layoutDerived = layoutRules ? layoutRules.evaluate(cell, record.layout_conventions) : null;
|
|
1234
|
+
} catch {
|
|
1235
|
+
layoutDerived = null;
|
|
1236
|
+
}
|
|
1237
|
+
if (!derivedOk(layoutDerived, layoutCheck)) {
|
|
1238
|
+
results.push(...failLayout(cell));
|
|
1239
|
+
layoutDerived = [];
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
for (const item of [...derived, ...layoutDerived]) {
|
|
1190
1243
|
const members = item.members ?? [];
|
|
1191
1244
|
const row = {
|
|
1192
1245
|
schema: QC_RESULT_SCHEMA,
|
|
1193
1246
|
id: qcResultId(item.subject),
|
|
1194
|
-
check,
|
|
1247
|
+
check: item.check,
|
|
1195
1248
|
leg: "polish",
|
|
1196
1249
|
result: item.result,
|
|
1197
1250
|
reason_code: item.result === "pass" ? null : item.reason_code,
|
|
@@ -1210,6 +1263,378 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1210
1263
|
return results;
|
|
1211
1264
|
}
|
|
1212
1265
|
|
|
1266
|
+
// Captures are re-derived from one caller-selected packet snapshot. Neither
|
|
1267
|
+
// the stored route set nor a stored decision can supply the capture plan.
|
|
1268
|
+
export function readCaptures({ record, packet, currentBuild, currentSource, buildOutput,
|
|
1269
|
+
campaignSlug, targetRepo, qcStandIns, rederivers } = {}) {
|
|
1270
|
+
const resolved = resolvePolishRules({ check: "visual.diff", qcStandIns, rederivers });
|
|
1271
|
+
if (resolved.status === "missing") return [];
|
|
1272
|
+
const rules = resolved.rules;
|
|
1273
|
+
let plan = null;
|
|
1274
|
+
try { if (packet && rules?.plan) plan = rules.plan({ packet, baseUrl: "http://127.0.0.1" }); } catch { /* fail closed below */ }
|
|
1275
|
+
const planned = Array.isArray(plan?.routes) ? plan.routes : [];
|
|
1276
|
+
const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
|
|
1277
|
+
// A changed build (§5.5 step 2) and a capture file not on this machine (step
|
|
1278
|
+
// 4.1, a fresh clone) are the row's own reason (§5.3 steps 2 and 9), as in
|
|
1279
|
+
// the sibling readers. Every other reader failure is evidence_not_reproducible
|
|
1280
|
+
// with the failure as its member. Either way every row of the record reads it.
|
|
1281
|
+
const failureRow = (subject, reason) => ["stale_binding", "capture_file_absent"].includes(reason)
|
|
1282
|
+
? buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: reason,
|
|
1283
|
+
state: { reason }, coverage: { observed: 1, expected: 1, limits: [reason] }, measured_at: measuredAt })
|
|
1284
|
+
: buildQcResult({ check: subject.check, leg: "polish", subject,
|
|
1285
|
+
result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: reason }],
|
|
1286
|
+
state: { reason }, measured_at: measuredAt });
|
|
1287
|
+
const subjectOf = (check, item) => ({ check, page: item.route,
|
|
1288
|
+
...(item.viewport == null ? {} : { viewport: item.viewport }),
|
|
1289
|
+
key: item.ref_id === "source_html" || item.basis === "source_html" ? "source_html" : `ref:${item.ref_id}` });
|
|
1290
|
+
const fallback = (reason, detail = null) => {
|
|
1291
|
+
const checks = qcChecksForRecord("captures");
|
|
1292
|
+
const subjects = planned.flatMap((route) => [
|
|
1293
|
+
{ check: "visual.reference", page: route.requested_route, key: "source_html" },
|
|
1294
|
+
...["desktop", "mobile"].map((viewport) => ({ check: "visual.diff", page: route.requested_route, viewport, key: "source_html" })),
|
|
1295
|
+
]);
|
|
1296
|
+
// Read identity only, even when integrity/schema validation failed. These
|
|
1297
|
+
// names preserve accept disposition; they never authorize evidence or a route.
|
|
1298
|
+
const readableIdentity = (item) => isPlainObject(item) && isNonEmptyString(item.route)
|
|
1299
|
+
&& (!plan || planned.some((route) => route.requested_route === item.route));
|
|
1300
|
+
for (const ref of Array.isArray(record?.references) ? record.references : []) {
|
|
1301
|
+
if (!readableIdentity(ref) || !isNonEmptyString(ref.ref_id)
|
|
1302
|
+
|| !(ref.viewport == null || isNonEmptyString(ref.viewport))) continue;
|
|
1303
|
+
subjects.push(subjectOf("visual.reference", ref));
|
|
1304
|
+
if (ref.ref_id !== "source_html" && isNonEmptyString(ref.viewport)) subjects.push(subjectOf("visual.diff", ref));
|
|
1305
|
+
}
|
|
1306
|
+
for (const comparison of Array.isArray(record?.comparisons) ? record.comparisons : []) {
|
|
1307
|
+
if (!readableIdentity(comparison) || !isNonEmptyString(comparison.viewport)
|
|
1308
|
+
|| !["source_html", "reference"].includes(comparison.basis)
|
|
1309
|
+
|| (comparison.basis === "reference" && !isNonEmptyString(comparison.ref_id))) continue;
|
|
1310
|
+
subjects.push(subjectOf("visual.diff", comparison));
|
|
1311
|
+
subjects.push(subjectOf("visual.reference", { ...comparison,
|
|
1312
|
+
ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
|
|
1313
|
+
viewport: comparison.basis === "source_html" ? null : comparison.viewport }));
|
|
1314
|
+
}
|
|
1315
|
+
if (!subjects.length) for (const check of checks) subjects.push({ check, page: null, key: "captures" });
|
|
1316
|
+
return [...new Map(subjects.map((subject) => [qcResultId(subject),
|
|
1317
|
+
{ ...failureRow(subject, reason), observation: { reader_detail: detail } }])).values()];
|
|
1318
|
+
};
|
|
1319
|
+
// Never ask the evaluator to traverse malformed evidence, even to report a
|
|
1320
|
+
// failure. Its input is a validated reference or one validated dependency set.
|
|
1321
|
+
const derive = (subset, context = {}) => {
|
|
1322
|
+
try { return rules.evaluate({ ...record, ...subset }, context); } catch { return []; }
|
|
1323
|
+
};
|
|
1324
|
+
const fail = (reason, detail) => fallback(reason, detail);
|
|
1325
|
+
if (!packet) return fail("packet_unavailable");
|
|
1326
|
+
if (!plan) return fail("packet_plan_invalid");
|
|
1327
|
+
if (resolved.status !== "loaded") return fail("rules_unavailable");
|
|
1328
|
+
const vocab = rules.vocabulary;
|
|
1329
|
+
const inVocab = (key, value) => Array.isArray(vocab[key]) && vocab[key].includes(value);
|
|
1330
|
+
const finite = (n) => typeof n === "number" && Number.isFinite(n);
|
|
1331
|
+
const nonnegative = (n) => finite(n) && n >= 0;
|
|
1332
|
+
const integer = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
1333
|
+
const canonicalTime = (s) => validTime(s) && new Date(s).toISOString() === s;
|
|
1334
|
+
const nullable = (value, test) => value === null || test(value);
|
|
1335
|
+
const optional = (object, key, test) => !Object.hasOwn(object, key) || test(object[key]);
|
|
1336
|
+
const strings = (value) => Array.isArray(value) && value.every(isNonEmptyString);
|
|
1337
|
+
const fingerprint = (value) => typeof value === "string" && FINGERPRINT.test(value);
|
|
1338
|
+
const rect = (r) => isPlainObject(r) && ["x", "y", "width", "height"].every((k) => finite(r[k])) && r.width >= 0 && r.height >= 0;
|
|
1339
|
+
const png = (p, dir) => isPlainObject(p) && fingerprint(p.sha256)
|
|
1340
|
+
&& p.path === `.campaign-runtime/polish/${dir}/${p.sha256.slice(7)}.png`
|
|
1341
|
+
&& integer(p.bytes) && p.bytes > 0 && integer(p.width_px) && p.width_px > 0
|
|
1342
|
+
&& integer(p.height_px) && p.height_px > 0 && p.width_px * p.height_px <= rules.limits.decode_pixels;
|
|
1343
|
+
if (!isPlainObject(record) || record.schema_version !== rules.schema_version
|
|
1344
|
+
|| record.performed_by !== QC_PRODUCERS.polish || record.integrity !== polishRecordIntegrity(record)
|
|
1345
|
+
|| record.treatment_version !== "visual-treatment/v1" || !canonicalTime(record.measured_at)
|
|
1346
|
+
|| !isPlainObject(record.limits) || !sameJson(record.limits, rules.limits)
|
|
1347
|
+
|| !isPlainObject(record.environment) || record.environment.device_scale_factor !== 1
|
|
1348
|
+
|| !isPlainObject(record.subject) || !isNonEmptyString(packet.campaign?.public_route_slug)
|
|
1349
|
+
|| record.subject.campaign_slug !== packet.campaign.public_route_slug || record.subject.campaign_slug !== campaignSlug
|
|
1350
|
+
|| !fingerprint(record.subject.build_fingerprint)
|
|
1351
|
+
|| !nullable(record.subject.source_package_material_fingerprint, fingerprint)
|
|
1352
|
+
|| !sameJson(record.subject.viewports, rules.viewports) || !["all", "selected"].includes(record.subject.route_scope)
|
|
1353
|
+
|| !strings(record.subject.routes) || new Set(record.subject.routes).size !== record.subject.routes.length
|
|
1354
|
+
|| !isPlainObject(record.dependencies) || !["decoder", "diff"].every((key) => isPlainObject(record.dependencies[key])
|
|
1355
|
+
&& inVocab("dependency_status", record.dependencies[key].status))
|
|
1356
|
+
|| !isPlainObject(record.adapters) || !["build", "source"].every((side) => isPlainObject(record.adapters[side])
|
|
1357
|
+
&& inVocab("startup", record.adapters[side].startup) && inVocab("close", record.adapters[side].close))
|
|
1358
|
+
|| record.adapters.source.sealed !== true || !isPlainObject(record.source)
|
|
1359
|
+
|| !inVocab("source_status", record.source.status) || record.source.server !== "loopback_read_only") return fail("record_shape_invalid");
|
|
1360
|
+
const routes = record.subject.routes;
|
|
1361
|
+
const cells = Array.isArray(record.cells) ? record.cells : [];
|
|
1362
|
+
const references = Array.isArray(record.references) ? record.references : [];
|
|
1363
|
+
const comparisons = Array.isArray(record.comparisons) ? record.comparisons : [];
|
|
1364
|
+
const cellIdentity = (cell) => isPlainObject(cell) && isNonEmptyString(cell.route) && rules.viewports.includes(cell.viewport) && inVocab("side", cell.side);
|
|
1365
|
+
const refIdentity = (ref) => isPlainObject(ref) && isNonEmptyString(ref.route) && isNonEmptyString(ref.ref_id)
|
|
1366
|
+
&& (ref.viewport === null || isNonEmptyString(ref.viewport));
|
|
1367
|
+
const comparisonIdentity = (c) => isPlainObject(c) && isNonEmptyString(c.route) && isNonEmptyString(c.viewport)
|
|
1368
|
+
&& ["source_html", "reference"].includes(c.basis) && (c.basis === "source_html" ? c.ref_id === null : isNonEmptyString(c.ref_id));
|
|
1369
|
+
const validateCell = (cell) => {
|
|
1370
|
+
if (!isPlainObject(cell)) return "cell_shape_invalid";
|
|
1371
|
+
if (!isNonEmptyString(cell.page_id) || !isPlainObject(cell.networkidle) || !isPlainObject(cell.readiness)
|
|
1372
|
+
|| !isPlainObject(cell.readiness.fonts) || !isPlainObject(cell.readiness.await) || !isPlainObject(cell.sections)
|
|
1373
|
+
|| !isPlainObject(cell.actual) || !isPlainObject(cell.stabilized)) return "cell_shape_invalid";
|
|
1374
|
+
if (!optional(cell.sections, "duplicated_keys", strings) || !optional(cell.sections, "capped", (v) => typeof v === "boolean")) return "cell_shape_invalid";
|
|
1375
|
+
if (!routes.includes(cell.route) || !inVocab("side", cell.side)
|
|
1376
|
+
|| !rules.viewports.includes(cell.viewport) || !inVocab("cell_status", cell.status)
|
|
1377
|
+
|| !inVocab("networkidle_status", cell.networkidle?.status)
|
|
1378
|
+
|| !inVocab("fonts_status", cell.readiness?.fonts?.status) || !inVocab("await_status", cell.readiness?.await?.status)
|
|
1379
|
+
|| !inVocab("section_status", cell.sections?.status) || !Array.isArray(cell.sections?.bands)
|
|
1380
|
+
|| cell.sections.bands.length > rules.limits.bands_per_page
|
|
1381
|
+
|| cell.sections.bands.some((b) => !isPlainObject(b) || !isNonEmptyString(b.key) || ![b.top, b.left, b.width, b.height].every(finite))) return "cell_shape_invalid";
|
|
1382
|
+
|
|
1383
|
+
if (cell.state !== "default_load" || cell.device_scale_factor !== 1) return "treatment_invalid";
|
|
1384
|
+
const fixed = cell.viewport === "desktop" ? [1440, 1200] : [390, 844];
|
|
1385
|
+
if (cell.width !== fixed[0] || cell.height !== fixed[1]) return "png_dimensions_mismatch";
|
|
1386
|
+
const treatment = cell.stabilized.treatment;
|
|
1387
|
+
if (!isPlainObject(treatment)) return "treatment_invalid";
|
|
1388
|
+
if (treatment?.mask_selectors !== "visual-masks/v1" || treatment.animations !== "disabled" || treatment.caret !== "hide"
|
|
1389
|
+
|| treatment.mask_color !== "#ff00ff" || typeof treatment.mask_cap_reached !== "boolean" || !Array.isArray(treatment.masks)
|
|
1390
|
+
|| treatment.masks.some((m) => !isPlainObject(m) || !inVocab("mask_group", m.group) || !integer(m.count) || !Array.isArray(m.rects) || !m.rects.every(rect))) return "treatment_invalid";
|
|
1391
|
+
for (const shot of [cell.actual, cell.stabilized]) {
|
|
1392
|
+
if (!inVocab("shot_status", shot?.status)) return "shot_shape_invalid";
|
|
1393
|
+
if (shot.status !== "captured") {
|
|
1394
|
+
if (!nullable(shot.png, (p) => png(p, "captures")) || !nullable(shot.crop, rect)
|
|
1395
|
+
|| !nullable(shot.full_height_css, nonnegative) || typeof shot.truncated !== "boolean") return "shot_shape_invalid";
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
if (!finite(shot.full_height_css) || shot.full_height_css <= 0 || !sameJson(shot.crop,
|
|
1399
|
+
{ x: 0, y: 0, width: cell.width, height: Math.min(shot.full_height_css, 12000) })
|
|
1400
|
+
|| shot.truncated !== (shot.full_height_css > 12000)) return "crop_not_full_page";
|
|
1401
|
+
if (!png(shot.png, "captures") || shot.png.width_px !== shot.crop.width || shot.png.height_px !== shot.crop.height) return "png_dimensions_mismatch";
|
|
1402
|
+
}
|
|
1403
|
+
return null;
|
|
1404
|
+
};
|
|
1405
|
+
const provenanceReasons = ["path_in_build_output", "path_in_runtime_dir", "hash_equals_package_capture", "linked_source_self_referential", "hash_equals_built_html", "source_is_campaign_page", "source_link_missing", "linked_source_missing", "source_hash_unrecorded", "source_hash_mismatch", "capture_time_unrecorded"];
|
|
1406
|
+
const inputReasons = ["source_root_unavailable", "source_html_missing", "source_package_unavailable", "source_package_stale"];
|
|
1407
|
+
const comparisonReasons = [...vocab.part_reason, "width_unknown", "dpr_unknown", "width_mismatch", "dpr_mismatch", "mask_cap_reached", "diff_budget_exhausted", "image_too_large", "decode_failed", "file_missing", "path_outside_source_root", "too_large", "diff_unavailable", "capture_unavailable", "reference_not_usable", "comparison_unsupported", "readiness_incomplete"];
|
|
1408
|
+
const validateReference = (ref) => {
|
|
1409
|
+
if (!isPlainObject(ref) || !isNonEmptyString(ref.page_id) || !isPlainObject(ref.verification)
|
|
1410
|
+
|| !isPlainObject(ref.provenance) || !isPlainObject(ref.identity)
|
|
1411
|
+
|| !nullable(ref.identity.sha256, fingerprint)
|
|
1412
|
+
|| !optional(ref.verification, "png", (p) => nullable(p, (v) => isPlainObject(v) && integer(v.width_px) && v.width_px > 0 && integer(v.height_px) && v.height_px > 0))
|
|
1413
|
+
|| !optional(ref, "css_width", (v) => nullable(v, nonnegative)) || !optional(ref, "dpr", (v) => nullable(v, nonnegative))
|
|
1414
|
+
|| !optional(ref, "reference_cap_reached", (v) => typeof v === "boolean") || !routes.includes(ref.route) || !isNonEmptyString(ref.ref_id)
|
|
1415
|
+
|| (ref.ref_id === "source_html" && ref.viewport !== null)
|
|
1416
|
+
|| !inVocab("verification_level", ref.verification?.level) || !inVocab("provenance_class", ref.provenance?.class)
|
|
1417
|
+
|| !["html_file", "source_html", "source_screenshot"].includes(ref.kind)
|
|
1418
|
+
|| !Array.isArray(ref.provenance.reasons) || ref.provenance.reasons.some((r) => !provenanceReasons.includes(r))
|
|
1419
|
+
|| (ref.input_reason != null && !inputReasons.includes(ref.input_reason))
|
|
1420
|
+
|| (ref.verification.reason != null && !inVocab("verification_reason", ref.verification.reason))) return "reference_shape_invalid";
|
|
1421
|
+
return null;
|
|
1422
|
+
};
|
|
1423
|
+
const validateComparison = (comparison, build, basis, reference) => {
|
|
1424
|
+
if (!isPlainObject(comparison) || !isNonEmptyString(comparison.page_id)
|
|
1425
|
+
|| !Array.isArray(comparison.members) || comparison.members.some((m) => !isPlainObject(m) || !isNonEmptyString(m.key)
|
|
1426
|
+
|| !QC_RESULTS.includes(m.result) || (m.result !== "pass" && !isNonEmptyString(m.reason_code ?? m.reason)))
|
|
1427
|
+
|| !isPlainObject(comparison.diff_options) || !routes.includes(comparison.route) || !["source_html", "reference"].includes(comparison.basis)
|
|
1428
|
+
|| (comparison.basis === "source_html" && !rules.viewports.includes(comparison.viewport))
|
|
1429
|
+
|| !inVocab("comparison_status", comparison.status)
|
|
1430
|
+
|| (comparison.reason != null && !comparisonReasons.includes(comparison.reason))
|
|
1431
|
+
|| !sameJson(comparison.diff_options, { threshold: 0.1, include_aa: false, alpha: 0.1 })
|
|
1432
|
+
|| !Array.isArray(comparison.limits) || comparison.limits.some((limit) => !["source_external_blocked", "reference_viewport_height_unrecorded", "top_anchored", "js_animation_not_frozen", "masks_unioned"].includes(limit))
|
|
1433
|
+
|| (comparison.page === null && (comparison.status === "compared"
|
|
1434
|
+
|| (comparison.status === "unsupported" && !["width_unknown", "dpr_unknown", "width_mismatch", "dpr_mismatch", "mask_cap_reached"].includes(comparison.reason))))
|
|
1435
|
+
|| !Array.isArray(comparison.bands) || comparison.bands.some((b) => !isPlainObject(b)) || !Array.isArray(comparison.artifacts)) return "comparison_shape_invalid";
|
|
1436
|
+
if (comparison.build_shot !== (comparison.basis === "reference" ? "actual" : "stabilized")
|
|
1437
|
+
|| comparison.basis_shot !== (comparison.basis === "reference" ? "image" : "stabilized")) return "comparison_shape_invalid";
|
|
1438
|
+
|
|
1439
|
+
if (comparison.basis === "source_html" && (!build || !basis)) return "comparison_shape_invalid";
|
|
1440
|
+
for (const part of [...(comparison.page === null ? [] : [comparison.page]), ...comparison.bands]) {
|
|
1441
|
+
if (!isPlainObject(part) || (part !== comparison.page && !isNonEmptyString(part.key))) return "metrics_invalid";
|
|
1442
|
+
if (Object.hasOwn(part, "worst_tiles") && !Array.isArray(part.worst_tiles)) return "metrics_invalid";
|
|
1443
|
+
if (!["compared", "unsupported"].includes(part.status)) return "metrics_invalid";
|
|
1444
|
+
if (!optional(part, "height_delta_px", finite)) return "metrics_invalid";
|
|
1445
|
+
if (part.status !== "compared") { if (!inVocab("part_reason", part.reason)) return "metrics_invalid"; continue; }
|
|
1446
|
+
if (!nonnegative(part.mismatch_ratio) || part.mismatch_ratio > 1 || !integer(part.mismatch_pixels)
|
|
1447
|
+
|| !integer(part.compared_pixels) || part.mismatch_pixels > part.compared_pixels
|
|
1448
|
+
|| !integer(part.masked_pixels) || !isPlainObject(part.canvas) || !integer(part.canvas?.width) || !integer(part.canvas?.height)
|
|
1449
|
+
|| part.canvas.width <= 0 || part.canvas.height <= 0
|
|
1450
|
+
|| part.compared_pixels + part.masked_pixels > part.canvas.width * part.canvas.height
|
|
1451
|
+
|| part.canvas.width * part.canvas.height > rules.limits.decode_pixels) return "metrics_invalid";
|
|
1452
|
+
for (const tile of part.worst_tiles ?? []) if (!rect(tile) || tile.x < 0 || tile.y < 0
|
|
1453
|
+
|| tile.x + tile.width > part.canvas.width || tile.y + tile.height > part.canvas.height
|
|
1454
|
+
|| !nonnegative(tile.mismatch_ratio) || tile.mismatch_ratio > 1) return "metrics_invalid";
|
|
1455
|
+
}
|
|
1456
|
+
const pixelSize = (cell, key) => {
|
|
1457
|
+
const boxes = (Array.isArray(cell?.sections?.bands) ? cell.sections.bands : []).filter((b) => b.key === key);
|
|
1458
|
+
if (boxes.length !== 1) return null;
|
|
1459
|
+
const [b] = boxes;
|
|
1460
|
+
if (b.top < 0 || b.left < 0 || b.top + b.height > cell.stabilized?.png?.height_px || b.left + b.width > cell.width) return null;
|
|
1461
|
+
return [Math.ceil(b.left + b.width) - Math.floor(b.left), Math.ceil(b.top + b.height) - Math.floor(b.top)];
|
|
1462
|
+
};
|
|
1463
|
+
for (const part of comparison.bands.filter((b) => b.status === "compared")) {
|
|
1464
|
+
const a = pixelSize(basis, part.key), b = pixelSize(build, part.key);
|
|
1465
|
+
if (!a || !b || !sameJson([part.canvas.width, part.canvas.height], [Math.max(a[0], b[0]), Math.max(a[1], b[1])])) return "band_geometry_invalid";
|
|
1466
|
+
}
|
|
1467
|
+
for (const artifact of comparison.artifacts) {
|
|
1468
|
+
if (!isPlainObject(artifact) || !isNonEmptyString(artifact.band_key)) return "artifact_geometry_invalid";
|
|
1469
|
+
const part = artifact.band_key === "page" ? comparison.page : comparison.bands.find((b) => b.key === artifact.band_key);
|
|
1470
|
+
if (!part || part.status !== "compared") return "artifact_geometry_invalid";
|
|
1471
|
+
const source = reference;
|
|
1472
|
+
const basisSize = artifact.band_key === "page" ? (comparison.basis === "reference" ? source?.verification?.png : basis?.stabilized?.png) : null;
|
|
1473
|
+
const sizes = { basis_crop: basisSize ? [basisSize.width_px, basisSize.height_px] : pixelSize(basis, part.key),
|
|
1474
|
+
build_crop: artifact.band_key === "page" ? [build?.[comparison.build_shot]?.png?.width_px, build?.[comparison.build_shot]?.png?.height_px] : pixelSize(build, part.key),
|
|
1475
|
+
heatmap: [part.canvas?.width, part.canvas?.height] };
|
|
1476
|
+
for (const name of Object.keys(sizes)) if (!png(artifact[name], "diff")
|
|
1477
|
+
|| !sameJson([artifact[name].width_px, artifact[name].height_px], sizes[name])) return "artifact_geometry_invalid";
|
|
1478
|
+
}
|
|
1479
|
+
return null;
|
|
1480
|
+
};
|
|
1481
|
+
const cellErrors = new Map(cells.filter(cellIdentity).map((c) => [c, validateCell(c)]));
|
|
1482
|
+
const refErrors = new Map(references.filter(refIdentity).map((r) => [r, validateReference(r)]));
|
|
1483
|
+
const cellGridInvalid = !Array.isArray(record.cells) || cells.length !== routes.length * 4
|
|
1484
|
+
|| cells.some((c) => !cellIdentity(c))
|
|
1485
|
+
|| new Set(cells.filter(cellIdentity).map((c) => JSON.stringify([c.route, c.viewport, c.side]))).size !== cells.length;
|
|
1486
|
+
const unknownReferences = !Array.isArray(record.references) || references.some((r) => !refIdentity(r));
|
|
1487
|
+
const unknownComparisons = !Array.isArray(record.comparisons) || comparisons.some((c) => !comparisonIdentity(c));
|
|
1488
|
+
if (cellGridInvalid) return fail("cell_grid_invalid");
|
|
1489
|
+
const cellFailure = [...cellErrors.values()].find(Boolean);
|
|
1490
|
+
if (cellFailure) return fail(cellFailure);
|
|
1491
|
+
if (unknownReferences) return fail("reference_shape_invalid");
|
|
1492
|
+
if (unknownComparisons) return fail("comparison_shape_invalid");
|
|
1493
|
+
if ([...refErrors.values()].some(Boolean)) return fail("reference_shape_invalid");
|
|
1494
|
+
const referenceKey = (ref) => JSON.stringify([ref.route, ref.viewport, ref.ref_id]);
|
|
1495
|
+
if (new Set(references.map(referenceKey)).size !== references.length) return fail("reference_shape_invalid");
|
|
1496
|
+
const stale = record.subject.build_fingerprint !== currentBuild
|
|
1497
|
+
|| record.subject.source_package_material_fingerprint !== (currentSource || null) || outputDrifted(buildOutput);
|
|
1498
|
+
const outsidePlan = routes.some((route) => !planned.some((p) => p.requested_route === route))
|
|
1499
|
+
|| cells.some((c) => !planned.some((p) => p.requested_route === c.route && p.page_id === c.page_id));
|
|
1500
|
+
const context = stale ? { staleBinding: true } : {};
|
|
1501
|
+
const rows = [];
|
|
1502
|
+
const referenceGroups = new Map(), cappedReferences = new Set();
|
|
1503
|
+
for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html") {
|
|
1504
|
+
const group = JSON.stringify([ref.page_id, ref.route, ref.viewport]);
|
|
1505
|
+
const items = referenceGroups.get(group) || [];
|
|
1506
|
+
if (items.length >= rules.limits.references_per_cell) cappedReferences.add(referenceKey(ref));
|
|
1507
|
+
items.push(ref); referenceGroups.set(group, items);
|
|
1508
|
+
}
|
|
1509
|
+
const safeReferences = references.filter((r) => refIdentity(r) && !refErrors.get(r) && !cappedReferences.has(referenceKey(r)))
|
|
1510
|
+
.map((ref) => referenceGroups.get(JSON.stringify([ref.page_id, ref.route, ref.viewport]))?.length > rules.limits.references_per_cell
|
|
1511
|
+
? { ...ref, reference_cap_reached: true } : ref);
|
|
1512
|
+
const referenceRows = derive({ cells: [], references: safeReferences, comparisons: [] }, context);
|
|
1513
|
+
for (const ref of references.filter(refIdentity)) {
|
|
1514
|
+
if (cappedReferences.has(referenceKey(ref))) continue;
|
|
1515
|
+
const subject = subjectOf("visual.reference", ref);
|
|
1516
|
+
const duplicates = references.filter(refIdentity).filter((r) => r.route === ref.route && r.viewport === ref.viewport && r.ref_id === ref.ref_id).length > 1;
|
|
1517
|
+
const failure = refErrors.get(ref) || (duplicates ? "reference_shape_invalid" : null);
|
|
1518
|
+
const row = referenceRows.find((r) => r.id === qcResultId(subject));
|
|
1519
|
+
if (failure || !row) return fail(failure || "reference_shape_invalid");
|
|
1520
|
+
rows.push({ ...row, state_fingerprint: qcStateFingerprint({ subject: row.subject,
|
|
1521
|
+
state: { kind: ref.kind, sha256: ref.identity.sha256, provenance_reasons: [...ref.provenance.reasons].sort() } }) });
|
|
1522
|
+
}
|
|
1523
|
+
const checked = new Map();
|
|
1524
|
+
const checkFile = (ref) => {
|
|
1525
|
+
// Read each file once, then compare every descriptor against those bytes.
|
|
1526
|
+
if (!checked.has(ref.path)) {
|
|
1527
|
+
let actual;
|
|
1528
|
+
try {
|
|
1529
|
+
const bytes = readRegularFileSync(resolve(targetRepo, ref.path), { root: targetRepo, noFollow: true, maxBytes: rules.limits.reference_bytes });
|
|
1530
|
+
actual = { hash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
|
1531
|
+
bytes: bytes.length, structure: readPngStructure(bytes) };
|
|
1532
|
+
} catch (error) { actual = error.code === "ENOENT" ? { absent: true } : { failure: captureReadFailure(error.code) }; }
|
|
1533
|
+
checked.set(ref.path, actual);
|
|
1534
|
+
}
|
|
1535
|
+
const actual = checked.get(ref.path);
|
|
1536
|
+
if (actual.absent || actual.failure) return actual;
|
|
1537
|
+
return actual.hash !== ref.sha256 ? { failure: "png_hash_mismatch" }
|
|
1538
|
+
: !actual.structure.ok ? { failure: "png_structure_invalid", detail: actual.structure.reason }
|
|
1539
|
+
: actual.bytes !== ref.bytes || actual.structure.ihdr.width_px !== ref.width_px || actual.structure.ihdr.height_px !== ref.height_px
|
|
1540
|
+
? { failure: "png_dimensions_mismatch" } : {};
|
|
1541
|
+
};
|
|
1542
|
+
const descriptors = cells.flatMap((c) => [c?.actual, c?.stabilized].filter((s) => s?.status === "captured").map((s) => s.png));
|
|
1543
|
+
for (const comparison of comparisons.filter(comparisonIdentity)) {
|
|
1544
|
+
const subject = subjectOf("visual.diff", comparison);
|
|
1545
|
+
const matching = (side) => cells.filter(cellIdentity).filter((c) => c.route === comparison.route && c.viewport === comparison.viewport && c.side === side);
|
|
1546
|
+
const builds = matching("build"), bases = matching("source_html"), build = builds[0], basis = bases[0];
|
|
1547
|
+
const refs = references.filter(refIdentity).filter((ref) => ref.route === comparison.route && (comparison.basis === "source_html"
|
|
1548
|
+
? ref.ref_id === "source_html" : ref.ref_id === comparison.ref_id && ref.viewport === comparison.viewport));
|
|
1549
|
+
const needed = comparison.basis === "source_html" ? [build, basis] : [build].filter(Boolean);
|
|
1550
|
+
const duplicates = comparisons.filter(comparisonIdentity).filter((c) => c.route === comparison.route && c.viewport === comparison.viewport && c.basis === comparison.basis && c.ref_id === comparison.ref_id).length > 1;
|
|
1551
|
+
const failure = (cellGridInvalid || builds.length > 1 || bases.length > 1 || needed.some((c) => !c) ? "cell_grid_invalid" : null)
|
|
1552
|
+
|| needed.map((c) => cellErrors.get(c)).find(Boolean)
|
|
1553
|
+
|| (refs.length !== 1 ? "reference_shape_invalid" : refErrors.get(refs[0]))
|
|
1554
|
+
|| (duplicates ? "comparison_shape_invalid" : null)
|
|
1555
|
+
|| validateComparison(comparison, build, comparison.basis === "reference" ? null : basis, refs[0]);
|
|
1556
|
+
if (failure) return fail(failure);
|
|
1557
|
+
descriptors.push(...comparison.artifacts.flatMap((a) => [a.basis_crop, a.build_crop, a.heatmap]));
|
|
1558
|
+
if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
|
|
1559
|
+
const subset = { cells: needed, references: safeReferences, comparisons: [comparison] };
|
|
1560
|
+
const row = derive(subset, context).find((r) => r.check === "visual.diff");
|
|
1561
|
+
if (!row) return fail("comparison_shape_invalid");
|
|
1562
|
+
rows.push(row);
|
|
1563
|
+
}
|
|
1564
|
+
// Every reader check invalidates the record, preserving all readable keys.
|
|
1565
|
+
for (const comparison of comparisons.filter(comparisonIdentity)) {
|
|
1566
|
+
if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
|
|
1567
|
+
const subject = subjectOf("visual.reference", { ...comparison, ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
|
|
1568
|
+
viewport: comparison.basis === "source_html" ? null : comparison.viewport });
|
|
1569
|
+
if (!rows.some((r) => r.id === qcResultId(subject))) return fail("reference_shape_invalid");
|
|
1570
|
+
}
|
|
1571
|
+
for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html" && !cappedReferences.has(referenceKey(ref))) {
|
|
1572
|
+
const subject = subjectOf("visual.diff", { ...ref, basis: "reference" });
|
|
1573
|
+
if (!rows.some((r) => r.id === qcResultId(subject))) return fail("comparison_shape_invalid");
|
|
1574
|
+
}
|
|
1575
|
+
for (const route of planned) {
|
|
1576
|
+
if (routes.includes(route.requested_route) && !rows.some((r) => r.check === "visual.reference" && r.subject.page === route.requested_route && r.subject.key === "source_html")) {
|
|
1577
|
+
return fail("comparison_grid_invalid");
|
|
1578
|
+
}
|
|
1579
|
+
for (const viewport of rules.viewports) {
|
|
1580
|
+
if (rows.some((r) => r.check === "visual.diff" && r.subject.page === route.requested_route && r.subject.viewport === viewport && r.subject.key === "source_html")) continue;
|
|
1581
|
+
const subject = { check: "visual.diff", page: route.requested_route, viewport, key: "source_html" };
|
|
1582
|
+
if (routes.includes(route.requested_route)) return fail("comparison_grid_invalid");
|
|
1583
|
+
rows.push(buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: "page_not_captured", state: { subject }, measured_at: measuredAt }));
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
if (stale) return fail("stale_binding");
|
|
1587
|
+
if (outsidePlan) return fail("route_outside_plan");
|
|
1588
|
+
// Structural checks precede binding/plan and files. No failure can leave an
|
|
1589
|
+
// independently acceptable reference or a sibling comparison behind.
|
|
1590
|
+
const descriptorsByPath = new Map();
|
|
1591
|
+
for (const ref of descriptors) {
|
|
1592
|
+
if (descriptorsByPath.has(ref.path) && !sameJson(descriptorsByPath.get(ref.path), ref)) return fail("png_dimensions_mismatch");
|
|
1593
|
+
descriptorsByPath.set(ref.path, ref);
|
|
1594
|
+
}
|
|
1595
|
+
// A file that is present but wrong outranks one that is absent (§5.3 step 1
|
|
1596
|
+
// before step 9), whichever is read first.
|
|
1597
|
+
const files = [...descriptorsByPath.values()].map(checkFile);
|
|
1598
|
+
const wrong = files.find((file) => file.failure);
|
|
1599
|
+
if (wrong) return fail(wrong.failure, wrong.detail);
|
|
1600
|
+
if (files.some((file) => file.absent)) return fail("capture_file_absent");
|
|
1601
|
+
const unique = new Map();
|
|
1602
|
+
for (const row of rows) if (!unique.has(row.id) || row.reason_code === E) unique.set(row.id, row);
|
|
1603
|
+
return unique.size ? [...unique.values()] : fail("record_shape_invalid");
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Element parity rows are re-derived by the rules module's reader from the
|
|
1607
|
+
// probe files, C's captures record, the packet snapshot, the Design Source
|
|
1608
|
+
// Package and the current build. A campaign with no mapped source page has no
|
|
1609
|
+
// parity.element row (applicability). A reader that cannot run reads every
|
|
1610
|
+
// row of the record evidence_not_reproducible, never pass.
|
|
1611
|
+
export const parityApplies = (packet) => Array.isArray(packet?.source_html?.pages) && packet.source_html.pages.some((page) => isNonEmptyString(page?.path));
|
|
1612
|
+
|
|
1613
|
+
export function readElementParity({ record, captures = null, packet = null, packetPath = null, currentBuild = null, currentSource = null,
|
|
1614
|
+
buildOutput = null, campaignSlug = null, targetRepo = null, qcAccepts = [], qcStandIns = null, rederivers = null } = {}) {
|
|
1615
|
+
if (packet && !parityApplies(packet)) return [];
|
|
1616
|
+
const resolved = resolvePolishRules({ check: "parity.element", qcStandIns, rederivers });
|
|
1617
|
+
const context = { record, captures, packet, packetPath, currentBuild, currentSource, buildOutput, campaignSlug, targetRepo, qcAccepts };
|
|
1618
|
+
if (resolved.status === "loaded" && typeof resolved.rules.read === "function") {
|
|
1619
|
+
try {
|
|
1620
|
+
const rows = resolved.rules.read(context, resolved.rules);
|
|
1621
|
+
if (Array.isArray(rows)) return rows;
|
|
1622
|
+
} catch { /* fail closed below */ }
|
|
1623
|
+
}
|
|
1624
|
+
const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
|
|
1625
|
+
const cells = (Array.isArray(record?.cells) ? record.cells : []).filter((cell) => isPlainObject(cell) && isNonEmptyString(cell.page_id) && ["desktop", "mobile"].includes(cell.viewport));
|
|
1626
|
+
const mapped = (Array.isArray(packet?.source_html?.pages) ? packet.source_html.pages : []).filter((page) => isNonEmptyString(page?.path) && isNonEmptyString(page.page_id));
|
|
1627
|
+
const subjects = [...cells.map((cell) => ({ check: "parity.element", page: cell.page_id, viewport: cell.viewport, key: "comparison" })),
|
|
1628
|
+
...mapped.flatMap((page) => ["desktop", "mobile"].map((viewport) => ({ check: "parity.element", page: page.page_id, viewport, key: "comparison" }))),
|
|
1629
|
+
...(Array.isArray(qcAccepts) ? qcAccepts : []).filter((accept) => accept?.check === "parity.element" && accept.leg === "polish"
|
|
1630
|
+
&& accept.subject?.check === "parity.element" && isNonEmptyString(accept.subject.page) && ["desktop", "mobile"].includes(accept.subject.viewport)
|
|
1631
|
+
&& /^(copy|type|color|geom):el-[a-f0-9]{12}$/.test(accept.subject.key)).map((accept) => accept.subject)];
|
|
1632
|
+
if (!subjects.length) subjects.push({ check: "parity.element", page: null, key: "element_parity" });
|
|
1633
|
+
return [...new Map(subjects.map((subject) => [qcResultId(subject), buildQcResult({ check: "parity.element", leg: "polish", subject,
|
|
1634
|
+
result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: "rules_unavailable" }],
|
|
1635
|
+
state: { reason: "rules_unavailable" }, measured_at: measuredAt })])).values()];
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1213
1638
|
// ---------------------------------------------------------------------------
|
|
1214
1639
|
// Every current result, per leg, and the silence rule
|
|
1215
1640
|
|
|
@@ -1222,8 +1647,16 @@ const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
|
|
|
1222
1647
|
// on its own.
|
|
1223
1648
|
const POLISH_RECORDS = Object.freeze({
|
|
1224
1649
|
media_weight: Object.freeze({ checks: Object.freeze(qcChecksForRecord("media_weight")), read: readMediaWeight }),
|
|
1650
|
+
captures: Object.freeze({ checks: Object.freeze(qcChecksForRecord("captures")), read: readCaptures }),
|
|
1225
1651
|
readability: Object.freeze({ checks: Object.freeze(qcChecksForRecord("readability")), read: readReadability }),
|
|
1652
|
+
element_parity: Object.freeze({ checks: Object.freeze(qcChecksForRecord("element_parity")), read: readElementParity }),
|
|
1226
1653
|
});
|
|
1654
|
+
// Records whose checks apply only to some campaigns, judged from the packet
|
|
1655
|
+
// snapshot. With no packet applicability is unknown: existing evidence still
|
|
1656
|
+
// applies, while an absent record keeps the no-packet behaviour.
|
|
1657
|
+
const POLISH_RECORD_APPLIES = Object.freeze({ element_parity: parityApplies });
|
|
1658
|
+
const polishRecordApplies = (name, packet, visual) => !Object.hasOwn(POLISH_RECORD_APPLIES, name)
|
|
1659
|
+
|| (packet != null ? POLISH_RECORD_APPLIES[name](packet) : visual?.[name] != null);
|
|
1227
1660
|
const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
|
|
1228
1661
|
|
|
1229
1662
|
// Applicability is decided without the leg running: the tracking checks
|
|
@@ -1255,7 +1688,7 @@ const silence = (leg, check, reasonCode) => ({ check, leg, result: "unexercised"
|
|
|
1255
1688
|
// record predates the check, its module is missing or not yet loaded, or the
|
|
1256
1689
|
// loaded rules gave no row), except that a module that failed to load reads
|
|
1257
1690
|
// evidence_not_reproducible when the record holds the leg's QC evidence.
|
|
1258
|
-
export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null }) {
|
|
1691
|
+
export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null, packet = null }) {
|
|
1259
1692
|
const entries = [];
|
|
1260
1693
|
const hasRow = (leg, check) => results.some((row) => row.leg === leg && row.check === check);
|
|
1261
1694
|
const silentReason = (failed) => (failed ? E : QC_REASON.NOT_CAPTURED);
|
|
@@ -1273,11 +1706,14 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
|
|
|
1273
1706
|
}
|
|
1274
1707
|
|
|
1275
1708
|
const polishStage = report?.stages?.polish;
|
|
1709
|
+
const visual = polishStage?.evidence?.visual_review;
|
|
1276
1710
|
if (!stageRecorded(polishStage)) {
|
|
1277
|
-
for (const check of POLISH_CHECKS)
|
|
1711
|
+
for (const check of POLISH_CHECKS) {
|
|
1712
|
+
if (polishRecordApplies(QC_CHECK_REGISTRY[check].record, packet, visual)) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
|
|
1713
|
+
}
|
|
1278
1714
|
} else {
|
|
1279
|
-
const visual = polishStage.evidence?.visual_review;
|
|
1280
1715
|
for (const [name, { checks }] of Object.entries(POLISH_RECORDS)) {
|
|
1716
|
+
if (!polishRecordApplies(name, packet, visual)) continue;
|
|
1281
1717
|
const captured = name === "media_weight" ? isPlainObject(visual?.page_load) && visual.media_weight != null : visual?.[name] != null;
|
|
1282
1718
|
for (const check of checks) {
|
|
1283
1719
|
if (hasRow("polish", check)) continue;
|
|
@@ -1291,17 +1727,10 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
|
|
|
1291
1727
|
|
|
1292
1728
|
// The campaign slug the Polish records bind to: the packet's, read from
|
|
1293
1729
|
// packetPath; with no readable packet, the Assembly Report's (which capture
|
|
1294
|
-
// requires to equal the packet's).
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const slug = JSON.parse(readFileSync(packetPath, "utf8"))?.campaign?.public_route_slug;
|
|
1299
|
-
return isNonEmptyString(slug) ? slug : null;
|
|
1300
|
-
} catch {
|
|
1301
|
-
// No readable packet: fall through to the report.
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
const slug = report?.identity?.public_route_slug;
|
|
1730
|
+
// requires to equal the packet's). A readable packet without one gives null,
|
|
1731
|
+
// as on main. Null when neither names one.
|
|
1732
|
+
export function packetCampaignSlug({ packet, packetRead = false, report }) {
|
|
1733
|
+
const slug = packetRead ? packet?.campaign?.public_route_slug : report?.identity?.public_route_slug;
|
|
1305
1734
|
return isNonEmptyString(slug) ? slug : null;
|
|
1306
1735
|
}
|
|
1307
1736
|
|
|
@@ -1313,13 +1742,19 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
|
|
|
1313
1742
|
const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
|
|
1314
1743
|
const visual = report?.stages?.polish?.evidence?.visual_review;
|
|
1315
1744
|
const recorded = isPlainObject(visual) ? Object.entries(POLISH_RECORDS).filter(([name]) => visual[name] != null) : [];
|
|
1745
|
+
let packet = null, packetRead = false;
|
|
1746
|
+
try { if (packetPath) { packet = JSON.parse(readRegularFileSync(packetPath, { maxBytes: 16 * 1024 * 1024 }).toString("utf8")); packetRead = true; } } catch { /* unavailable */ }
|
|
1316
1747
|
const polishContext = recorded.length ? {
|
|
1317
1748
|
pageLoad: visual.page_load,
|
|
1749
|
+
captures: visual.captures ?? null,
|
|
1750
|
+
packet,
|
|
1751
|
+
packetPath,
|
|
1318
1752
|
currentBuild,
|
|
1319
1753
|
currentSource: currentSourcePackageMaterialFingerprint(report),
|
|
1320
1754
|
buildOutput: doctor?.derived?.build_output_fingerprint ?? null,
|
|
1321
|
-
campaignSlug: packetCampaignSlug({
|
|
1755
|
+
campaignSlug: packetCampaignSlug({ packet, packetRead, report }),
|
|
1322
1756
|
targetRepo,
|
|
1757
|
+
qcAccepts: report?.qc_accepts,
|
|
1323
1758
|
qcStandIns,
|
|
1324
1759
|
rederivers,
|
|
1325
1760
|
} : null;
|
|
@@ -1337,5 +1772,5 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
|
|
|
1337
1772
|
})
|
|
1338
1773
|
: [];
|
|
1339
1774
|
const results = [...doctorResults, ...polishResults, ...qaResults];
|
|
1340
|
-
return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers }) };
|
|
1775
|
+
return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers, packet }) };
|
|
1341
1776
|
}
|