@nextcommerce/campaigns-os 1.59.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 +242 -0
- package/README.md +11 -10
- 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 +1311 -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 +42 -13
- package/docs/campaign-build-brief.md +17 -1
- package/docs/campaigns-os-build-flow.md +62 -3
- 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 +41 -3
- 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 +5 -5
- 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 +12 -16
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
- package/skills.json +18 -10
- 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 +7 -14
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +89 -73
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +14 -77
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +20 -8
- package/src/doctor/next-step.mjs +6 -3
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -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 +26 -11
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/progress-node.mjs +2 -1
- 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/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +63 -9
- 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
|
@@ -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";
|
|
@@ -831,6 +833,10 @@ const PAGE_NOT_CAPTURED = "page_not_captured";
|
|
|
831
833
|
// output under _site/<slug>/ is the one `record build` recorded. Any other
|
|
832
834
|
// value, null included, is drift. A caller that passes no value (undefined)
|
|
833
835
|
// reads without the output check.
|
|
836
|
+
// A capture file that is there but cannot be read as a regular file names
|
|
837
|
+
// why; only a missing file is capture_file_absent.
|
|
838
|
+
const captureReadFailure = (code) => code === "too_large" ? "capture_file_too_large"
|
|
839
|
+
: ["ELOOP", "EISDIR", "ENOTDIR", "not_regular_file", "outside_root"].includes(code) ? "capture_file_not_regular" : "capture_file_unreadable";
|
|
834
840
|
const outputDrifted = (buildOutput) => buildOutput !== undefined && buildOutput?.status !== "pass";
|
|
835
841
|
|
|
836
842
|
export function readMediaWeight({ record, pageLoad, currentBuild = null, buildOutput = undefined, qcStandIns = null, rederivers = null } = {}) {
|
|
@@ -1210,6 +1216,378 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1210
1216
|
return results;
|
|
1211
1217
|
}
|
|
1212
1218
|
|
|
1219
|
+
// Captures are re-derived from one caller-selected packet snapshot. Neither
|
|
1220
|
+
// the stored route set nor a stored decision can supply the capture plan.
|
|
1221
|
+
export function readCaptures({ record, packet, currentBuild, currentSource, buildOutput,
|
|
1222
|
+
campaignSlug, targetRepo, qcStandIns, rederivers } = {}) {
|
|
1223
|
+
const resolved = resolvePolishRules({ check: "visual.diff", qcStandIns, rederivers });
|
|
1224
|
+
if (resolved.status === "missing") return [];
|
|
1225
|
+
const rules = resolved.rules;
|
|
1226
|
+
let plan = null;
|
|
1227
|
+
try { if (packet && rules?.plan) plan = rules.plan({ packet, baseUrl: "http://127.0.0.1" }); } catch { /* fail closed below */ }
|
|
1228
|
+
const planned = Array.isArray(plan?.routes) ? plan.routes : [];
|
|
1229
|
+
const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
|
|
1230
|
+
// A changed build (§5.5 step 2) and a capture file not on this machine (step
|
|
1231
|
+
// 4.1, a fresh clone) are the row's own reason (§5.3 steps 2 and 9), as in
|
|
1232
|
+
// the sibling readers. Every other reader failure is evidence_not_reproducible
|
|
1233
|
+
// with the failure as its member. Either way every row of the record reads it.
|
|
1234
|
+
const failureRow = (subject, reason) => ["stale_binding", "capture_file_absent"].includes(reason)
|
|
1235
|
+
? buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: reason,
|
|
1236
|
+
state: { reason }, coverage: { observed: 1, expected: 1, limits: [reason] }, measured_at: measuredAt })
|
|
1237
|
+
: buildQcResult({ check: subject.check, leg: "polish", subject,
|
|
1238
|
+
result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: reason }],
|
|
1239
|
+
state: { reason }, measured_at: measuredAt });
|
|
1240
|
+
const subjectOf = (check, item) => ({ check, page: item.route,
|
|
1241
|
+
...(item.viewport == null ? {} : { viewport: item.viewport }),
|
|
1242
|
+
key: item.ref_id === "source_html" || item.basis === "source_html" ? "source_html" : `ref:${item.ref_id}` });
|
|
1243
|
+
const fallback = (reason, detail = null) => {
|
|
1244
|
+
const checks = qcChecksForRecord("captures");
|
|
1245
|
+
const subjects = planned.flatMap((route) => [
|
|
1246
|
+
{ check: "visual.reference", page: route.requested_route, key: "source_html" },
|
|
1247
|
+
...["desktop", "mobile"].map((viewport) => ({ check: "visual.diff", page: route.requested_route, viewport, key: "source_html" })),
|
|
1248
|
+
]);
|
|
1249
|
+
// Read identity only, even when integrity/schema validation failed. These
|
|
1250
|
+
// names preserve accept disposition; they never authorize evidence or a route.
|
|
1251
|
+
const readableIdentity = (item) => isPlainObject(item) && isNonEmptyString(item.route)
|
|
1252
|
+
&& (!plan || planned.some((route) => route.requested_route === item.route));
|
|
1253
|
+
for (const ref of Array.isArray(record?.references) ? record.references : []) {
|
|
1254
|
+
if (!readableIdentity(ref) || !isNonEmptyString(ref.ref_id)
|
|
1255
|
+
|| !(ref.viewport == null || isNonEmptyString(ref.viewport))) continue;
|
|
1256
|
+
subjects.push(subjectOf("visual.reference", ref));
|
|
1257
|
+
if (ref.ref_id !== "source_html" && isNonEmptyString(ref.viewport)) subjects.push(subjectOf("visual.diff", ref));
|
|
1258
|
+
}
|
|
1259
|
+
for (const comparison of Array.isArray(record?.comparisons) ? record.comparisons : []) {
|
|
1260
|
+
if (!readableIdentity(comparison) || !isNonEmptyString(comparison.viewport)
|
|
1261
|
+
|| !["source_html", "reference"].includes(comparison.basis)
|
|
1262
|
+
|| (comparison.basis === "reference" && !isNonEmptyString(comparison.ref_id))) continue;
|
|
1263
|
+
subjects.push(subjectOf("visual.diff", comparison));
|
|
1264
|
+
subjects.push(subjectOf("visual.reference", { ...comparison,
|
|
1265
|
+
ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
|
|
1266
|
+
viewport: comparison.basis === "source_html" ? null : comparison.viewport }));
|
|
1267
|
+
}
|
|
1268
|
+
if (!subjects.length) for (const check of checks) subjects.push({ check, page: null, key: "captures" });
|
|
1269
|
+
return [...new Map(subjects.map((subject) => [qcResultId(subject),
|
|
1270
|
+
{ ...failureRow(subject, reason), observation: { reader_detail: detail } }])).values()];
|
|
1271
|
+
};
|
|
1272
|
+
// Never ask the evaluator to traverse malformed evidence, even to report a
|
|
1273
|
+
// failure. Its input is a validated reference or one validated dependency set.
|
|
1274
|
+
const derive = (subset, context = {}) => {
|
|
1275
|
+
try { return rules.evaluate({ ...record, ...subset }, context); } catch { return []; }
|
|
1276
|
+
};
|
|
1277
|
+
const fail = (reason, detail) => fallback(reason, detail);
|
|
1278
|
+
if (!packet) return fail("packet_unavailable");
|
|
1279
|
+
if (!plan) return fail("packet_plan_invalid");
|
|
1280
|
+
if (resolved.status !== "loaded") return fail("rules_unavailable");
|
|
1281
|
+
const vocab = rules.vocabulary;
|
|
1282
|
+
const inVocab = (key, value) => Array.isArray(vocab[key]) && vocab[key].includes(value);
|
|
1283
|
+
const finite = (n) => typeof n === "number" && Number.isFinite(n);
|
|
1284
|
+
const nonnegative = (n) => finite(n) && n >= 0;
|
|
1285
|
+
const integer = (n) => Number.isSafeInteger(n) && n >= 0;
|
|
1286
|
+
const canonicalTime = (s) => validTime(s) && new Date(s).toISOString() === s;
|
|
1287
|
+
const nullable = (value, test) => value === null || test(value);
|
|
1288
|
+
const optional = (object, key, test) => !Object.hasOwn(object, key) || test(object[key]);
|
|
1289
|
+
const strings = (value) => Array.isArray(value) && value.every(isNonEmptyString);
|
|
1290
|
+
const fingerprint = (value) => typeof value === "string" && FINGERPRINT.test(value);
|
|
1291
|
+
const rect = (r) => isPlainObject(r) && ["x", "y", "width", "height"].every((k) => finite(r[k])) && r.width >= 0 && r.height >= 0;
|
|
1292
|
+
const png = (p, dir) => isPlainObject(p) && fingerprint(p.sha256)
|
|
1293
|
+
&& p.path === `.campaign-runtime/polish/${dir}/${p.sha256.slice(7)}.png`
|
|
1294
|
+
&& integer(p.bytes) && p.bytes > 0 && integer(p.width_px) && p.width_px > 0
|
|
1295
|
+
&& integer(p.height_px) && p.height_px > 0 && p.width_px * p.height_px <= rules.limits.decode_pixels;
|
|
1296
|
+
if (!isPlainObject(record) || record.schema_version !== rules.schema_version
|
|
1297
|
+
|| record.performed_by !== QC_PRODUCERS.polish || record.integrity !== polishRecordIntegrity(record)
|
|
1298
|
+
|| record.treatment_version !== "visual-treatment/v1" || !canonicalTime(record.measured_at)
|
|
1299
|
+
|| !isPlainObject(record.limits) || !sameJson(record.limits, rules.limits)
|
|
1300
|
+
|| !isPlainObject(record.environment) || record.environment.device_scale_factor !== 1
|
|
1301
|
+
|| !isPlainObject(record.subject) || !isNonEmptyString(packet.campaign?.public_route_slug)
|
|
1302
|
+
|| record.subject.campaign_slug !== packet.campaign.public_route_slug || record.subject.campaign_slug !== campaignSlug
|
|
1303
|
+
|| !fingerprint(record.subject.build_fingerprint)
|
|
1304
|
+
|| !nullable(record.subject.source_package_material_fingerprint, fingerprint)
|
|
1305
|
+
|| !sameJson(record.subject.viewports, rules.viewports) || !["all", "selected"].includes(record.subject.route_scope)
|
|
1306
|
+
|| !strings(record.subject.routes) || new Set(record.subject.routes).size !== record.subject.routes.length
|
|
1307
|
+
|| !isPlainObject(record.dependencies) || !["decoder", "diff"].every((key) => isPlainObject(record.dependencies[key])
|
|
1308
|
+
&& inVocab("dependency_status", record.dependencies[key].status))
|
|
1309
|
+
|| !isPlainObject(record.adapters) || !["build", "source"].every((side) => isPlainObject(record.adapters[side])
|
|
1310
|
+
&& inVocab("startup", record.adapters[side].startup) && inVocab("close", record.adapters[side].close))
|
|
1311
|
+
|| record.adapters.source.sealed !== true || !isPlainObject(record.source)
|
|
1312
|
+
|| !inVocab("source_status", record.source.status) || record.source.server !== "loopback_read_only") return fail("record_shape_invalid");
|
|
1313
|
+
const routes = record.subject.routes;
|
|
1314
|
+
const cells = Array.isArray(record.cells) ? record.cells : [];
|
|
1315
|
+
const references = Array.isArray(record.references) ? record.references : [];
|
|
1316
|
+
const comparisons = Array.isArray(record.comparisons) ? record.comparisons : [];
|
|
1317
|
+
const cellIdentity = (cell) => isPlainObject(cell) && isNonEmptyString(cell.route) && rules.viewports.includes(cell.viewport) && inVocab("side", cell.side);
|
|
1318
|
+
const refIdentity = (ref) => isPlainObject(ref) && isNonEmptyString(ref.route) && isNonEmptyString(ref.ref_id)
|
|
1319
|
+
&& (ref.viewport === null || isNonEmptyString(ref.viewport));
|
|
1320
|
+
const comparisonIdentity = (c) => isPlainObject(c) && isNonEmptyString(c.route) && isNonEmptyString(c.viewport)
|
|
1321
|
+
&& ["source_html", "reference"].includes(c.basis) && (c.basis === "source_html" ? c.ref_id === null : isNonEmptyString(c.ref_id));
|
|
1322
|
+
const validateCell = (cell) => {
|
|
1323
|
+
if (!isPlainObject(cell)) return "cell_shape_invalid";
|
|
1324
|
+
if (!isNonEmptyString(cell.page_id) || !isPlainObject(cell.networkidle) || !isPlainObject(cell.readiness)
|
|
1325
|
+
|| !isPlainObject(cell.readiness.fonts) || !isPlainObject(cell.readiness.await) || !isPlainObject(cell.sections)
|
|
1326
|
+
|| !isPlainObject(cell.actual) || !isPlainObject(cell.stabilized)) return "cell_shape_invalid";
|
|
1327
|
+
if (!optional(cell.sections, "duplicated_keys", strings) || !optional(cell.sections, "capped", (v) => typeof v === "boolean")) return "cell_shape_invalid";
|
|
1328
|
+
if (!routes.includes(cell.route) || !inVocab("side", cell.side)
|
|
1329
|
+
|| !rules.viewports.includes(cell.viewport) || !inVocab("cell_status", cell.status)
|
|
1330
|
+
|| !inVocab("networkidle_status", cell.networkidle?.status)
|
|
1331
|
+
|| !inVocab("fonts_status", cell.readiness?.fonts?.status) || !inVocab("await_status", cell.readiness?.await?.status)
|
|
1332
|
+
|| !inVocab("section_status", cell.sections?.status) || !Array.isArray(cell.sections?.bands)
|
|
1333
|
+
|| cell.sections.bands.length > rules.limits.bands_per_page
|
|
1334
|
+
|| cell.sections.bands.some((b) => !isPlainObject(b) || !isNonEmptyString(b.key) || ![b.top, b.left, b.width, b.height].every(finite))) return "cell_shape_invalid";
|
|
1335
|
+
|
|
1336
|
+
if (cell.state !== "default_load" || cell.device_scale_factor !== 1) return "treatment_invalid";
|
|
1337
|
+
const fixed = cell.viewport === "desktop" ? [1440, 1200] : [390, 844];
|
|
1338
|
+
if (cell.width !== fixed[0] || cell.height !== fixed[1]) return "png_dimensions_mismatch";
|
|
1339
|
+
const treatment = cell.stabilized.treatment;
|
|
1340
|
+
if (!isPlainObject(treatment)) return "treatment_invalid";
|
|
1341
|
+
if (treatment?.mask_selectors !== "visual-masks/v1" || treatment.animations !== "disabled" || treatment.caret !== "hide"
|
|
1342
|
+
|| treatment.mask_color !== "#ff00ff" || typeof treatment.mask_cap_reached !== "boolean" || !Array.isArray(treatment.masks)
|
|
1343
|
+
|| 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";
|
|
1344
|
+
for (const shot of [cell.actual, cell.stabilized]) {
|
|
1345
|
+
if (!inVocab("shot_status", shot?.status)) return "shot_shape_invalid";
|
|
1346
|
+
if (shot.status !== "captured") {
|
|
1347
|
+
if (!nullable(shot.png, (p) => png(p, "captures")) || !nullable(shot.crop, rect)
|
|
1348
|
+
|| !nullable(shot.full_height_css, nonnegative) || typeof shot.truncated !== "boolean") return "shot_shape_invalid";
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (!finite(shot.full_height_css) || shot.full_height_css <= 0 || !sameJson(shot.crop,
|
|
1352
|
+
{ x: 0, y: 0, width: cell.width, height: Math.min(shot.full_height_css, 12000) })
|
|
1353
|
+
|| shot.truncated !== (shot.full_height_css > 12000)) return "crop_not_full_page";
|
|
1354
|
+
if (!png(shot.png, "captures") || shot.png.width_px !== shot.crop.width || shot.png.height_px !== shot.crop.height) return "png_dimensions_mismatch";
|
|
1355
|
+
}
|
|
1356
|
+
return null;
|
|
1357
|
+
};
|
|
1358
|
+
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"];
|
|
1359
|
+
const inputReasons = ["source_root_unavailable", "source_html_missing", "source_package_unavailable", "source_package_stale"];
|
|
1360
|
+
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"];
|
|
1361
|
+
const validateReference = (ref) => {
|
|
1362
|
+
if (!isPlainObject(ref) || !isNonEmptyString(ref.page_id) || !isPlainObject(ref.verification)
|
|
1363
|
+
|| !isPlainObject(ref.provenance) || !isPlainObject(ref.identity)
|
|
1364
|
+
|| !nullable(ref.identity.sha256, fingerprint)
|
|
1365
|
+
|| !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))
|
|
1366
|
+
|| !optional(ref, "css_width", (v) => nullable(v, nonnegative)) || !optional(ref, "dpr", (v) => nullable(v, nonnegative))
|
|
1367
|
+
|| !optional(ref, "reference_cap_reached", (v) => typeof v === "boolean") || !routes.includes(ref.route) || !isNonEmptyString(ref.ref_id)
|
|
1368
|
+
|| (ref.ref_id === "source_html" && ref.viewport !== null)
|
|
1369
|
+
|| !inVocab("verification_level", ref.verification?.level) || !inVocab("provenance_class", ref.provenance?.class)
|
|
1370
|
+
|| !["html_file", "source_html", "source_screenshot"].includes(ref.kind)
|
|
1371
|
+
|| !Array.isArray(ref.provenance.reasons) || ref.provenance.reasons.some((r) => !provenanceReasons.includes(r))
|
|
1372
|
+
|| (ref.input_reason != null && !inputReasons.includes(ref.input_reason))
|
|
1373
|
+
|| (ref.verification.reason != null && !inVocab("verification_reason", ref.verification.reason))) return "reference_shape_invalid";
|
|
1374
|
+
return null;
|
|
1375
|
+
};
|
|
1376
|
+
const validateComparison = (comparison, build, basis, reference) => {
|
|
1377
|
+
if (!isPlainObject(comparison) || !isNonEmptyString(comparison.page_id)
|
|
1378
|
+
|| !Array.isArray(comparison.members) || comparison.members.some((m) => !isPlainObject(m) || !isNonEmptyString(m.key)
|
|
1379
|
+
|| !QC_RESULTS.includes(m.result) || (m.result !== "pass" && !isNonEmptyString(m.reason_code ?? m.reason)))
|
|
1380
|
+
|| !isPlainObject(comparison.diff_options) || !routes.includes(comparison.route) || !["source_html", "reference"].includes(comparison.basis)
|
|
1381
|
+
|| (comparison.basis === "source_html" && !rules.viewports.includes(comparison.viewport))
|
|
1382
|
+
|| !inVocab("comparison_status", comparison.status)
|
|
1383
|
+
|| (comparison.reason != null && !comparisonReasons.includes(comparison.reason))
|
|
1384
|
+
|| !sameJson(comparison.diff_options, { threshold: 0.1, include_aa: false, alpha: 0.1 })
|
|
1385
|
+
|| !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))
|
|
1386
|
+
|| (comparison.page === null && (comparison.status === "compared"
|
|
1387
|
+
|| (comparison.status === "unsupported" && !["width_unknown", "dpr_unknown", "width_mismatch", "dpr_mismatch", "mask_cap_reached"].includes(comparison.reason))))
|
|
1388
|
+
|| !Array.isArray(comparison.bands) || comparison.bands.some((b) => !isPlainObject(b)) || !Array.isArray(comparison.artifacts)) return "comparison_shape_invalid";
|
|
1389
|
+
if (comparison.build_shot !== (comparison.basis === "reference" ? "actual" : "stabilized")
|
|
1390
|
+
|| comparison.basis_shot !== (comparison.basis === "reference" ? "image" : "stabilized")) return "comparison_shape_invalid";
|
|
1391
|
+
|
|
1392
|
+
if (comparison.basis === "source_html" && (!build || !basis)) return "comparison_shape_invalid";
|
|
1393
|
+
for (const part of [...(comparison.page === null ? [] : [comparison.page]), ...comparison.bands]) {
|
|
1394
|
+
if (!isPlainObject(part) || (part !== comparison.page && !isNonEmptyString(part.key))) return "metrics_invalid";
|
|
1395
|
+
if (Object.hasOwn(part, "worst_tiles") && !Array.isArray(part.worst_tiles)) return "metrics_invalid";
|
|
1396
|
+
if (!["compared", "unsupported"].includes(part.status)) return "metrics_invalid";
|
|
1397
|
+
if (!optional(part, "height_delta_px", finite)) return "metrics_invalid";
|
|
1398
|
+
if (part.status !== "compared") { if (!inVocab("part_reason", part.reason)) return "metrics_invalid"; continue; }
|
|
1399
|
+
if (!nonnegative(part.mismatch_ratio) || part.mismatch_ratio > 1 || !integer(part.mismatch_pixels)
|
|
1400
|
+
|| !integer(part.compared_pixels) || part.mismatch_pixels > part.compared_pixels
|
|
1401
|
+
|| !integer(part.masked_pixels) || !isPlainObject(part.canvas) || !integer(part.canvas?.width) || !integer(part.canvas?.height)
|
|
1402
|
+
|| part.canvas.width <= 0 || part.canvas.height <= 0
|
|
1403
|
+
|| part.compared_pixels + part.masked_pixels > part.canvas.width * part.canvas.height
|
|
1404
|
+
|| part.canvas.width * part.canvas.height > rules.limits.decode_pixels) return "metrics_invalid";
|
|
1405
|
+
for (const tile of part.worst_tiles ?? []) if (!rect(tile) || tile.x < 0 || tile.y < 0
|
|
1406
|
+
|| tile.x + tile.width > part.canvas.width || tile.y + tile.height > part.canvas.height
|
|
1407
|
+
|| !nonnegative(tile.mismatch_ratio) || tile.mismatch_ratio > 1) return "metrics_invalid";
|
|
1408
|
+
}
|
|
1409
|
+
const pixelSize = (cell, key) => {
|
|
1410
|
+
const boxes = (Array.isArray(cell?.sections?.bands) ? cell.sections.bands : []).filter((b) => b.key === key);
|
|
1411
|
+
if (boxes.length !== 1) return null;
|
|
1412
|
+
const [b] = boxes;
|
|
1413
|
+
if (b.top < 0 || b.left < 0 || b.top + b.height > cell.stabilized?.png?.height_px || b.left + b.width > cell.width) return null;
|
|
1414
|
+
return [Math.ceil(b.left + b.width) - Math.floor(b.left), Math.ceil(b.top + b.height) - Math.floor(b.top)];
|
|
1415
|
+
};
|
|
1416
|
+
for (const part of comparison.bands.filter((b) => b.status === "compared")) {
|
|
1417
|
+
const a = pixelSize(basis, part.key), b = pixelSize(build, part.key);
|
|
1418
|
+
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";
|
|
1419
|
+
}
|
|
1420
|
+
for (const artifact of comparison.artifacts) {
|
|
1421
|
+
if (!isPlainObject(artifact) || !isNonEmptyString(artifact.band_key)) return "artifact_geometry_invalid";
|
|
1422
|
+
const part = artifact.band_key === "page" ? comparison.page : comparison.bands.find((b) => b.key === artifact.band_key);
|
|
1423
|
+
if (!part || part.status !== "compared") return "artifact_geometry_invalid";
|
|
1424
|
+
const source = reference;
|
|
1425
|
+
const basisSize = artifact.band_key === "page" ? (comparison.basis === "reference" ? source?.verification?.png : basis?.stabilized?.png) : null;
|
|
1426
|
+
const sizes = { basis_crop: basisSize ? [basisSize.width_px, basisSize.height_px] : pixelSize(basis, part.key),
|
|
1427
|
+
build_crop: artifact.band_key === "page" ? [build?.[comparison.build_shot]?.png?.width_px, build?.[comparison.build_shot]?.png?.height_px] : pixelSize(build, part.key),
|
|
1428
|
+
heatmap: [part.canvas?.width, part.canvas?.height] };
|
|
1429
|
+
for (const name of Object.keys(sizes)) if (!png(artifact[name], "diff")
|
|
1430
|
+
|| !sameJson([artifact[name].width_px, artifact[name].height_px], sizes[name])) return "artifact_geometry_invalid";
|
|
1431
|
+
}
|
|
1432
|
+
return null;
|
|
1433
|
+
};
|
|
1434
|
+
const cellErrors = new Map(cells.filter(cellIdentity).map((c) => [c, validateCell(c)]));
|
|
1435
|
+
const refErrors = new Map(references.filter(refIdentity).map((r) => [r, validateReference(r)]));
|
|
1436
|
+
const cellGridInvalid = !Array.isArray(record.cells) || cells.length !== routes.length * 4
|
|
1437
|
+
|| cells.some((c) => !cellIdentity(c))
|
|
1438
|
+
|| new Set(cells.filter(cellIdentity).map((c) => JSON.stringify([c.route, c.viewport, c.side]))).size !== cells.length;
|
|
1439
|
+
const unknownReferences = !Array.isArray(record.references) || references.some((r) => !refIdentity(r));
|
|
1440
|
+
const unknownComparisons = !Array.isArray(record.comparisons) || comparisons.some((c) => !comparisonIdentity(c));
|
|
1441
|
+
if (cellGridInvalid) return fail("cell_grid_invalid");
|
|
1442
|
+
const cellFailure = [...cellErrors.values()].find(Boolean);
|
|
1443
|
+
if (cellFailure) return fail(cellFailure);
|
|
1444
|
+
if (unknownReferences) return fail("reference_shape_invalid");
|
|
1445
|
+
if (unknownComparisons) return fail("comparison_shape_invalid");
|
|
1446
|
+
if ([...refErrors.values()].some(Boolean)) return fail("reference_shape_invalid");
|
|
1447
|
+
const referenceKey = (ref) => JSON.stringify([ref.route, ref.viewport, ref.ref_id]);
|
|
1448
|
+
if (new Set(references.map(referenceKey)).size !== references.length) return fail("reference_shape_invalid");
|
|
1449
|
+
const stale = record.subject.build_fingerprint !== currentBuild
|
|
1450
|
+
|| record.subject.source_package_material_fingerprint !== (currentSource || null) || outputDrifted(buildOutput);
|
|
1451
|
+
const outsidePlan = routes.some((route) => !planned.some((p) => p.requested_route === route))
|
|
1452
|
+
|| cells.some((c) => !planned.some((p) => p.requested_route === c.route && p.page_id === c.page_id));
|
|
1453
|
+
const context = stale ? { staleBinding: true } : {};
|
|
1454
|
+
const rows = [];
|
|
1455
|
+
const referenceGroups = new Map(), cappedReferences = new Set();
|
|
1456
|
+
for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html") {
|
|
1457
|
+
const group = JSON.stringify([ref.page_id, ref.route, ref.viewport]);
|
|
1458
|
+
const items = referenceGroups.get(group) || [];
|
|
1459
|
+
if (items.length >= rules.limits.references_per_cell) cappedReferences.add(referenceKey(ref));
|
|
1460
|
+
items.push(ref); referenceGroups.set(group, items);
|
|
1461
|
+
}
|
|
1462
|
+
const safeReferences = references.filter((r) => refIdentity(r) && !refErrors.get(r) && !cappedReferences.has(referenceKey(r)))
|
|
1463
|
+
.map((ref) => referenceGroups.get(JSON.stringify([ref.page_id, ref.route, ref.viewport]))?.length > rules.limits.references_per_cell
|
|
1464
|
+
? { ...ref, reference_cap_reached: true } : ref);
|
|
1465
|
+
const referenceRows = derive({ cells: [], references: safeReferences, comparisons: [] }, context);
|
|
1466
|
+
for (const ref of references.filter(refIdentity)) {
|
|
1467
|
+
if (cappedReferences.has(referenceKey(ref))) continue;
|
|
1468
|
+
const subject = subjectOf("visual.reference", ref);
|
|
1469
|
+
const duplicates = references.filter(refIdentity).filter((r) => r.route === ref.route && r.viewport === ref.viewport && r.ref_id === ref.ref_id).length > 1;
|
|
1470
|
+
const failure = refErrors.get(ref) || (duplicates ? "reference_shape_invalid" : null);
|
|
1471
|
+
const row = referenceRows.find((r) => r.id === qcResultId(subject));
|
|
1472
|
+
if (failure || !row) return fail(failure || "reference_shape_invalid");
|
|
1473
|
+
rows.push({ ...row, state_fingerprint: qcStateFingerprint({ subject: row.subject,
|
|
1474
|
+
state: { kind: ref.kind, sha256: ref.identity.sha256, provenance_reasons: [...ref.provenance.reasons].sort() } }) });
|
|
1475
|
+
}
|
|
1476
|
+
const checked = new Map();
|
|
1477
|
+
const checkFile = (ref) => {
|
|
1478
|
+
// Read each file once, then compare every descriptor against those bytes.
|
|
1479
|
+
if (!checked.has(ref.path)) {
|
|
1480
|
+
let actual;
|
|
1481
|
+
try {
|
|
1482
|
+
const bytes = readRegularFileSync(resolve(targetRepo, ref.path), { root: targetRepo, noFollow: true, maxBytes: rules.limits.reference_bytes });
|
|
1483
|
+
actual = { hash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
|
1484
|
+
bytes: bytes.length, structure: readPngStructure(bytes) };
|
|
1485
|
+
} catch (error) { actual = error.code === "ENOENT" ? { absent: true } : { failure: captureReadFailure(error.code) }; }
|
|
1486
|
+
checked.set(ref.path, actual);
|
|
1487
|
+
}
|
|
1488
|
+
const actual = checked.get(ref.path);
|
|
1489
|
+
if (actual.absent || actual.failure) return actual;
|
|
1490
|
+
return actual.hash !== ref.sha256 ? { failure: "png_hash_mismatch" }
|
|
1491
|
+
: !actual.structure.ok ? { failure: "png_structure_invalid", detail: actual.structure.reason }
|
|
1492
|
+
: actual.bytes !== ref.bytes || actual.structure.ihdr.width_px !== ref.width_px || actual.structure.ihdr.height_px !== ref.height_px
|
|
1493
|
+
? { failure: "png_dimensions_mismatch" } : {};
|
|
1494
|
+
};
|
|
1495
|
+
const descriptors = cells.flatMap((c) => [c?.actual, c?.stabilized].filter((s) => s?.status === "captured").map((s) => s.png));
|
|
1496
|
+
for (const comparison of comparisons.filter(comparisonIdentity)) {
|
|
1497
|
+
const subject = subjectOf("visual.diff", comparison);
|
|
1498
|
+
const matching = (side) => cells.filter(cellIdentity).filter((c) => c.route === comparison.route && c.viewport === comparison.viewport && c.side === side);
|
|
1499
|
+
const builds = matching("build"), bases = matching("source_html"), build = builds[0], basis = bases[0];
|
|
1500
|
+
const refs = references.filter(refIdentity).filter((ref) => ref.route === comparison.route && (comparison.basis === "source_html"
|
|
1501
|
+
? ref.ref_id === "source_html" : ref.ref_id === comparison.ref_id && ref.viewport === comparison.viewport));
|
|
1502
|
+
const needed = comparison.basis === "source_html" ? [build, basis] : [build].filter(Boolean);
|
|
1503
|
+
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;
|
|
1504
|
+
const failure = (cellGridInvalid || builds.length > 1 || bases.length > 1 || needed.some((c) => !c) ? "cell_grid_invalid" : null)
|
|
1505
|
+
|| needed.map((c) => cellErrors.get(c)).find(Boolean)
|
|
1506
|
+
|| (refs.length !== 1 ? "reference_shape_invalid" : refErrors.get(refs[0]))
|
|
1507
|
+
|| (duplicates ? "comparison_shape_invalid" : null)
|
|
1508
|
+
|| validateComparison(comparison, build, comparison.basis === "reference" ? null : basis, refs[0]);
|
|
1509
|
+
if (failure) return fail(failure);
|
|
1510
|
+
descriptors.push(...comparison.artifacts.flatMap((a) => [a.basis_crop, a.build_crop, a.heatmap]));
|
|
1511
|
+
if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
|
|
1512
|
+
const subset = { cells: needed, references: safeReferences, comparisons: [comparison] };
|
|
1513
|
+
const row = derive(subset, context).find((r) => r.check === "visual.diff");
|
|
1514
|
+
if (!row) return fail("comparison_shape_invalid");
|
|
1515
|
+
rows.push(row);
|
|
1516
|
+
}
|
|
1517
|
+
// Every reader check invalidates the record, preserving all readable keys.
|
|
1518
|
+
for (const comparison of comparisons.filter(comparisonIdentity)) {
|
|
1519
|
+
if (comparison.basis === "reference" && cappedReferences.has(referenceKey(comparison))) continue;
|
|
1520
|
+
const subject = subjectOf("visual.reference", { ...comparison, ref_id: comparison.basis === "source_html" ? "source_html" : comparison.ref_id,
|
|
1521
|
+
viewport: comparison.basis === "source_html" ? null : comparison.viewport });
|
|
1522
|
+
if (!rows.some((r) => r.id === qcResultId(subject))) return fail("reference_shape_invalid");
|
|
1523
|
+
}
|
|
1524
|
+
for (const ref of references.filter(refIdentity)) if (ref.ref_id !== "source_html" && !cappedReferences.has(referenceKey(ref))) {
|
|
1525
|
+
const subject = subjectOf("visual.diff", { ...ref, basis: "reference" });
|
|
1526
|
+
if (!rows.some((r) => r.id === qcResultId(subject))) return fail("comparison_shape_invalid");
|
|
1527
|
+
}
|
|
1528
|
+
for (const route of planned) {
|
|
1529
|
+
if (routes.includes(route.requested_route) && !rows.some((r) => r.check === "visual.reference" && r.subject.page === route.requested_route && r.subject.key === "source_html")) {
|
|
1530
|
+
return fail("comparison_grid_invalid");
|
|
1531
|
+
}
|
|
1532
|
+
for (const viewport of rules.viewports) {
|
|
1533
|
+
if (rows.some((r) => r.check === "visual.diff" && r.subject.page === route.requested_route && r.subject.viewport === viewport && r.subject.key === "source_html")) continue;
|
|
1534
|
+
const subject = { check: "visual.diff", page: route.requested_route, viewport, key: "source_html" };
|
|
1535
|
+
if (routes.includes(route.requested_route)) return fail("comparison_grid_invalid");
|
|
1536
|
+
rows.push(buildQcResult({ check: subject.check, leg: "polish", subject, result: "unexercised", reason_code: "page_not_captured", state: { subject }, measured_at: measuredAt }));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
if (stale) return fail("stale_binding");
|
|
1540
|
+
if (outsidePlan) return fail("route_outside_plan");
|
|
1541
|
+
// Structural checks precede binding/plan and files. No failure can leave an
|
|
1542
|
+
// independently acceptable reference or a sibling comparison behind.
|
|
1543
|
+
const descriptorsByPath = new Map();
|
|
1544
|
+
for (const ref of descriptors) {
|
|
1545
|
+
if (descriptorsByPath.has(ref.path) && !sameJson(descriptorsByPath.get(ref.path), ref)) return fail("png_dimensions_mismatch");
|
|
1546
|
+
descriptorsByPath.set(ref.path, ref);
|
|
1547
|
+
}
|
|
1548
|
+
// A file that is present but wrong outranks one that is absent (§5.3 step 1
|
|
1549
|
+
// before step 9), whichever is read first.
|
|
1550
|
+
const files = [...descriptorsByPath.values()].map(checkFile);
|
|
1551
|
+
const wrong = files.find((file) => file.failure);
|
|
1552
|
+
if (wrong) return fail(wrong.failure, wrong.detail);
|
|
1553
|
+
if (files.some((file) => file.absent)) return fail("capture_file_absent");
|
|
1554
|
+
const unique = new Map();
|
|
1555
|
+
for (const row of rows) if (!unique.has(row.id) || row.reason_code === E) unique.set(row.id, row);
|
|
1556
|
+
return unique.size ? [...unique.values()] : fail("record_shape_invalid");
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Element parity rows are re-derived by the rules module's reader from the
|
|
1560
|
+
// probe files, C's captures record, the packet snapshot, the Design Source
|
|
1561
|
+
// Package and the current build. A campaign with no mapped source page has no
|
|
1562
|
+
// parity.element row (applicability). A reader that cannot run reads every
|
|
1563
|
+
// row of the record evidence_not_reproducible, never pass.
|
|
1564
|
+
export const parityApplies = (packet) => Array.isArray(packet?.source_html?.pages) && packet.source_html.pages.some((page) => isNonEmptyString(page?.path));
|
|
1565
|
+
|
|
1566
|
+
export function readElementParity({ record, captures = null, packet = null, packetPath = null, currentBuild = null, currentSource = null,
|
|
1567
|
+
buildOutput = null, campaignSlug = null, targetRepo = null, qcAccepts = [], qcStandIns = null, rederivers = null } = {}) {
|
|
1568
|
+
if (packet && !parityApplies(packet)) return [];
|
|
1569
|
+
const resolved = resolvePolishRules({ check: "parity.element", qcStandIns, rederivers });
|
|
1570
|
+
const context = { record, captures, packet, packetPath, currentBuild, currentSource, buildOutput, campaignSlug, targetRepo, qcAccepts };
|
|
1571
|
+
if (resolved.status === "loaded" && typeof resolved.rules.read === "function") {
|
|
1572
|
+
try {
|
|
1573
|
+
const rows = resolved.rules.read(context, resolved.rules);
|
|
1574
|
+
if (Array.isArray(rows)) return rows;
|
|
1575
|
+
} catch { /* fail closed below */ }
|
|
1576
|
+
}
|
|
1577
|
+
const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
|
|
1578
|
+
const cells = (Array.isArray(record?.cells) ? record.cells : []).filter((cell) => isPlainObject(cell) && isNonEmptyString(cell.page_id) && ["desktop", "mobile"].includes(cell.viewport));
|
|
1579
|
+
const mapped = (Array.isArray(packet?.source_html?.pages) ? packet.source_html.pages : []).filter((page) => isNonEmptyString(page?.path) && isNonEmptyString(page.page_id));
|
|
1580
|
+
const subjects = [...cells.map((cell) => ({ check: "parity.element", page: cell.page_id, viewport: cell.viewport, key: "comparison" })),
|
|
1581
|
+
...mapped.flatMap((page) => ["desktop", "mobile"].map((viewport) => ({ check: "parity.element", page: page.page_id, viewport, key: "comparison" }))),
|
|
1582
|
+
...(Array.isArray(qcAccepts) ? qcAccepts : []).filter((accept) => accept?.check === "parity.element" && accept.leg === "polish"
|
|
1583
|
+
&& accept.subject?.check === "parity.element" && isNonEmptyString(accept.subject.page) && ["desktop", "mobile"].includes(accept.subject.viewport)
|
|
1584
|
+
&& /^(copy|type|color|geom):el-[a-f0-9]{12}$/.test(accept.subject.key)).map((accept) => accept.subject)];
|
|
1585
|
+
if (!subjects.length) subjects.push({ check: "parity.element", page: null, key: "element_parity" });
|
|
1586
|
+
return [...new Map(subjects.map((subject) => [qcResultId(subject), buildQcResult({ check: "parity.element", leg: "polish", subject,
|
|
1587
|
+
result: "unexercised", reason_code: E, members: [{ key: "evidence", result: "unexercised", reason_code: "rules_unavailable" }],
|
|
1588
|
+
state: { reason: "rules_unavailable" }, measured_at: measuredAt })])).values()];
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1213
1591
|
// ---------------------------------------------------------------------------
|
|
1214
1592
|
// Every current result, per leg, and the silence rule
|
|
1215
1593
|
|
|
@@ -1222,8 +1600,16 @@ const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
|
|
|
1222
1600
|
// on its own.
|
|
1223
1601
|
const POLISH_RECORDS = Object.freeze({
|
|
1224
1602
|
media_weight: Object.freeze({ checks: Object.freeze(qcChecksForRecord("media_weight")), read: readMediaWeight }),
|
|
1603
|
+
captures: Object.freeze({ checks: Object.freeze(qcChecksForRecord("captures")), read: readCaptures }),
|
|
1225
1604
|
readability: Object.freeze({ checks: Object.freeze(qcChecksForRecord("readability")), read: readReadability }),
|
|
1605
|
+
element_parity: Object.freeze({ checks: Object.freeze(qcChecksForRecord("element_parity")), read: readElementParity }),
|
|
1226
1606
|
});
|
|
1607
|
+
// Records whose checks apply only to some campaigns, judged from the packet
|
|
1608
|
+
// snapshot. With no packet applicability is unknown: existing evidence still
|
|
1609
|
+
// applies, while an absent record keeps the no-packet behaviour.
|
|
1610
|
+
const POLISH_RECORD_APPLIES = Object.freeze({ element_parity: parityApplies });
|
|
1611
|
+
const polishRecordApplies = (name, packet, visual) => !Object.hasOwn(POLISH_RECORD_APPLIES, name)
|
|
1612
|
+
|| (packet != null ? POLISH_RECORD_APPLIES[name](packet) : visual?.[name] != null);
|
|
1227
1613
|
const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
|
|
1228
1614
|
|
|
1229
1615
|
// Applicability is decided without the leg running: the tracking checks
|
|
@@ -1255,7 +1641,7 @@ const silence = (leg, check, reasonCode) => ({ check, leg, result: "unexercised"
|
|
|
1255
1641
|
// record predates the check, its module is missing or not yet loaded, or the
|
|
1256
1642
|
// loaded rules gave no row), except that a module that failed to load reads
|
|
1257
1643
|
// evidence_not_reproducible when the record holds the leg's QC evidence.
|
|
1258
|
-
export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null }) {
|
|
1644
|
+
export function handoffCoverage({ report, spec, results = [], qcStandIns = null, rederivers = null, packet = null }) {
|
|
1259
1645
|
const entries = [];
|
|
1260
1646
|
const hasRow = (leg, check) => results.some((row) => row.leg === leg && row.check === check);
|
|
1261
1647
|
const silentReason = (failed) => (failed ? E : QC_REASON.NOT_CAPTURED);
|
|
@@ -1273,11 +1659,14 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
|
|
|
1273
1659
|
}
|
|
1274
1660
|
|
|
1275
1661
|
const polishStage = report?.stages?.polish;
|
|
1662
|
+
const visual = polishStage?.evidence?.visual_review;
|
|
1276
1663
|
if (!stageRecorded(polishStage)) {
|
|
1277
|
-
for (const check of POLISH_CHECKS)
|
|
1664
|
+
for (const check of POLISH_CHECKS) {
|
|
1665
|
+
if (polishRecordApplies(QC_CHECK_REGISTRY[check].record, packet, visual)) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
|
|
1666
|
+
}
|
|
1278
1667
|
} else {
|
|
1279
|
-
const visual = polishStage.evidence?.visual_review;
|
|
1280
1668
|
for (const [name, { checks }] of Object.entries(POLISH_RECORDS)) {
|
|
1669
|
+
if (!polishRecordApplies(name, packet, visual)) continue;
|
|
1281
1670
|
const captured = name === "media_weight" ? isPlainObject(visual?.page_load) && visual.media_weight != null : visual?.[name] != null;
|
|
1282
1671
|
for (const check of checks) {
|
|
1283
1672
|
if (hasRow("polish", check)) continue;
|
|
@@ -1291,17 +1680,10 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
|
|
|
1291
1680
|
|
|
1292
1681
|
// The campaign slug the Polish records bind to: the packet's, read from
|
|
1293
1682
|
// 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;
|
|
1683
|
+
// requires to equal the packet's). A readable packet without one gives null,
|
|
1684
|
+
// as on main. Null when neither names one.
|
|
1685
|
+
export function packetCampaignSlug({ packet, packetRead = false, report }) {
|
|
1686
|
+
const slug = packetRead ? packet?.campaign?.public_route_slug : report?.identity?.public_route_slug;
|
|
1305
1687
|
return isNonEmptyString(slug) ? slug : null;
|
|
1306
1688
|
}
|
|
1307
1689
|
|
|
@@ -1313,13 +1695,19 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
|
|
|
1313
1695
|
const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
|
|
1314
1696
|
const visual = report?.stages?.polish?.evidence?.visual_review;
|
|
1315
1697
|
const recorded = isPlainObject(visual) ? Object.entries(POLISH_RECORDS).filter(([name]) => visual[name] != null) : [];
|
|
1698
|
+
let packet = null, packetRead = false;
|
|
1699
|
+
try { if (packetPath) { packet = JSON.parse(readRegularFileSync(packetPath, { maxBytes: 16 * 1024 * 1024 }).toString("utf8")); packetRead = true; } } catch { /* unavailable */ }
|
|
1316
1700
|
const polishContext = recorded.length ? {
|
|
1317
1701
|
pageLoad: visual.page_load,
|
|
1702
|
+
captures: visual.captures ?? null,
|
|
1703
|
+
packet,
|
|
1704
|
+
packetPath,
|
|
1318
1705
|
currentBuild,
|
|
1319
1706
|
currentSource: currentSourcePackageMaterialFingerprint(report),
|
|
1320
1707
|
buildOutput: doctor?.derived?.build_output_fingerprint ?? null,
|
|
1321
|
-
campaignSlug: packetCampaignSlug({
|
|
1708
|
+
campaignSlug: packetCampaignSlug({ packet, packetRead, report }),
|
|
1322
1709
|
targetRepo,
|
|
1710
|
+
qcAccepts: report?.qc_accepts,
|
|
1323
1711
|
qcStandIns,
|
|
1324
1712
|
rederivers,
|
|
1325
1713
|
} : null;
|
|
@@ -1337,5 +1725,5 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
|
|
|
1337
1725
|
})
|
|
1338
1726
|
: [];
|
|
1339
1727
|
const results = [...doctorResults, ...polishResults, ...qaResults];
|
|
1340
|
-
return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers }) };
|
|
1728
|
+
return { results, coverage: handoffCoverage({ report, spec, results, qcStandIns, rederivers, packet }) };
|
|
1341
1729
|
}
|