@nextcommerce/campaigns-os 1.52.0 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +2 -0
- package/agents/copilot/copilot-instructions.md +2 -0
- package/agents/cursor/campaigns-os.mdc +2 -0
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +201 -12
- package/contracts/release-ledger.json +327 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +56 -15
- package/docs/campaign-build-brief.md +48 -17
- package/docs/effects.md +8 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +2 -0
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +10 -19
- package/skills/next-campaigns-os/references/session-intake.md +10 -6
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +5 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +21 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +167 -15
- package/src/cli.mjs +160 -112
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +129 -14
- package/src/doctor/next-step.mjs +15 -6
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +3 -1
- package/src/polish-browser.mjs +503 -201
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-node.mjs +326 -46
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +15 -3
- package/src/qa-browser.mjs +131 -84
- package/src/qa-node.mjs +14 -3
- package/src/qc-accept.mjs +44 -8
- package/src/qc-check-registry.mjs +15 -7
- package/src/qc-results.mjs +326 -34
- package/src/readback.mjs +58 -10
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +119 -26
- package/src/tooling-setup.mjs +1 -1
package/src/progress-node.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync} from
|
|
|
5
5
|
import {join,resolve,dirname} from 'node:path';
|
|
6
6
|
import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
|
|
7
7
|
import {specMaterialHash} from './spec-identity.mjs';
|
|
8
|
+
import {effectiveStageStatus} from './input-currency.mjs';
|
|
8
9
|
import {sameFile} from './fs-identity.mjs';
|
|
9
10
|
import {withDirectoryLock} from './directory-lock.mjs';
|
|
10
11
|
import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
|
|
@@ -17,7 +18,7 @@ const accepted = (value,values,fallback='unknown')=>values.includes(value)?value
|
|
|
17
18
|
// A next action that is a variant of a fixed action projects onto it: the
|
|
18
19
|
// order-bump QA run is a QA run. Without this, the closed v0 vocabulary would
|
|
19
20
|
// read it as unknown and mark a ready QA continuation blocked.
|
|
20
|
-
const PROGRESS_ACTION_ALIASES = new Map([['qa_run_bump','qa_run']]);
|
|
21
|
+
const PROGRESS_ACTION_ALIASES = new Map([['qa_run_bump','qa_run'],['refresh_inputs','recheck']]);
|
|
21
22
|
const hash = value=>typeof value==='string'&&/^(?:sha256:)?[0-9a-f]{64}$/i.test(value)?`sha256:${value.replace(/^sha256:/i,'').toLowerCase()}`:null;
|
|
22
23
|
const id = value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value)?value:null;
|
|
23
24
|
const digest = value=>`sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
@@ -54,7 +55,15 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
54
55
|
const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
|
|
55
56
|
const build=hash(doctor?.derived?.build_output_fingerprint?.value);
|
|
56
57
|
const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
|
|
57
|
-
|
|
58
|
+
// The report belongs to this packet and spec: its stages can be read. A
|
|
59
|
+
// spec edit since the report was bound does not unbind it; the edit makes
|
|
60
|
+
// the stamped stages owed, which their effective status reports. The
|
|
61
|
+
// doctor read must have assessed the same spec material this read sees.
|
|
62
|
+
const inputCurrency=doctor?.derived?.input_currency;
|
|
63
|
+
const stagesBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&campaignIdentitiesMatch(packet?.spec,campaignSpecIdentity(spec))&&campaignIdentitiesMatch(packet?.spec,report?.identity)&&localHash&&(!inputCurrency||hash(inputCurrency.spec?.current)===localHash);
|
|
64
|
+
// The report is also bound to the spec material on disk now, so output and
|
|
65
|
+
// verdict evidence recorded against it can match.
|
|
66
|
+
const reportBound=stagesBound&&localHash===hash(report?.identity?.spec_material_hash);
|
|
58
67
|
const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
|
|
59
68
|
const verdict=qaResult?.verdict;
|
|
60
69
|
const qa=verdict?{
|
|
@@ -73,8 +82,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
73
82
|
return {
|
|
74
83
|
schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
|
|
75
84
|
identity:{map_id:mapId,...localSpecIdentityFields(packet?.spec),map_revision_hash:mapId?(contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null)):null,map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
|
|
85
|
+
// Each stage's effective status: a stage owed again by a brief or
|
|
86
|
+
// CampaignSpec change reads required, one whose inputs cannot be
|
|
87
|
+
// confirmed reads unknown (src/input-currency.mjs).
|
|
76
88
|
stages:PROGRESS_STAGES.map(stage=>{
|
|
77
|
-
const status=
|
|
89
|
+
const status=stagesBound?accepted(effectiveStageStatus(stage,report?.stages?.[stage],inputCurrency),PROGRESS_STAGE_STATUSES):'unknown';
|
|
78
90
|
const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
|
|
79
91
|
return {stage,status,source_build_fingerprint:source,build_binding:reportBound&&build&&source===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'?'matching':'unconfirmed'};
|
|
80
92
|
}),
|
package/src/qa-browser.mjs
CHANGED
|
@@ -6,6 +6,8 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { runWithDeadline } from "./deadline.mjs";
|
|
8
8
|
import { PLACEHOLDER_TEXT_ASSERTION_SUFFIX, SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
9
|
+
import { contrastToolkit } from "./contrast.mjs";
|
|
10
|
+
import { generatedTextRenders } from "./polish-readability.mjs";
|
|
9
11
|
import {
|
|
10
12
|
analyticsCaptureError,
|
|
11
13
|
projectAnalyticsCaptureError,
|
|
@@ -1300,9 +1302,7 @@ async function primaryCtaVisualAssertions(browserPage, page) {
|
|
|
1300
1302
|
}
|
|
1301
1303
|
|
|
1302
1304
|
function primaryCtaCheckEligible(page) {
|
|
1303
|
-
|
|
1304
|
-
const pageType = String(page.page_type || "").toLowerCase();
|
|
1305
|
-
return !["checkout", "upsell", "downsell", "thankyou", "receipt"].includes(pageType);
|
|
1305
|
+
return Boolean(page?.expected_next_url);
|
|
1306
1306
|
}
|
|
1307
1307
|
|
|
1308
1308
|
// Candidate CTAs: anything clickable, plus every SDK action control and the
|
|
@@ -1314,10 +1314,12 @@ const PRIMARY_CTA_SELECTOR = ["a[href]", "button", "[role='button']", "[data-nex
|
|
|
1314
1314
|
|
|
1315
1315
|
// The in-page half of the primary-CTA inspection, as the source text the
|
|
1316
1316
|
// page evaluates. The route rule it needs (cartEntryHrefFor, unit-tested in
|
|
1317
|
-
// qa-cart-entry)
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1317
|
+
// qa-cart-entry), the shared contrast helper (contrastToolkit) and the
|
|
1318
|
+
// generated-text rule Polish reads (generatedTextRenders) are handed in as
|
|
1319
|
+
// function values rather than closed over, so every function body must
|
|
1320
|
+
// stay free of module-scope references: the text is run in a fresh context by
|
|
1321
|
+
// a test (primary-CTA inspection script is self-contained) that would surface
|
|
1322
|
+
// a leaked identifier as a ReferenceError.
|
|
1321
1323
|
function primaryCtaInspectionScript(expectedUrl) {
|
|
1322
1324
|
const args = {
|
|
1323
1325
|
routeUrl: expectedUrl,
|
|
@@ -1326,7 +1328,7 @@ function primaryCtaInspectionScript(expectedUrl) {
|
|
|
1326
1328
|
cartEntryRouteAttribute: CART_ENTRY_ROUTE_ATTRIBUTE,
|
|
1327
1329
|
ignoredRouteAttributes: [...UNDECLARED_ROUTE_ATTRIBUTES],
|
|
1328
1330
|
};
|
|
1329
|
-
return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()})`;
|
|
1331
|
+
return `(${inspectPrimaryCtaScript.toString()})(${JSON.stringify(args)}, ${cartEntryHrefFor.toString()}, ${contrastToolkit.toString()}, ${generatedTextRenders.toString()})`;
|
|
1330
1332
|
}
|
|
1331
1333
|
|
|
1332
1334
|
async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
@@ -1340,8 +1342,9 @@ async function inspectPrimaryCta(browserPage, expectedUrl) {
|
|
|
1340
1342
|
}));
|
|
1341
1343
|
}
|
|
1342
1344
|
|
|
1343
|
-
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl) {
|
|
1345
|
+
function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, cartEntryRouteAttribute, ignoredRouteAttributes }, hrefForImpl, contrastToolkitImpl, generatedTextImpl) {
|
|
1344
1346
|
const CTA_SELECTOR = ctaSelector;
|
|
1347
|
+
const toolkit = contrastToolkitImpl();
|
|
1345
1348
|
|
|
1346
1349
|
const trim = (value) => String(value || "").replace(/\s+/g, " ").trim();
|
|
1347
1350
|
const compactPath = (value) => String(value || "").replace(/\/+$/, "") || "/";
|
|
@@ -1352,42 +1355,6 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1352
1355
|
return null;
|
|
1353
1356
|
}
|
|
1354
1357
|
})();
|
|
1355
|
-
const parseColor = (value) => {
|
|
1356
|
-
const raw = String(value || "").trim().toLowerCase();
|
|
1357
|
-
if (!raw || raw === "transparent") return null;
|
|
1358
|
-
const rgb = raw.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d?(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/);
|
|
1359
|
-
if (!rgb) return null;
|
|
1360
|
-
const parts = rgb.slice(1, 4).map((part) => Number(part));
|
|
1361
|
-
if (parts.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) return null;
|
|
1362
|
-
const alpha = rgb[4] === undefined ? 1 : Number(rgb[4]);
|
|
1363
|
-
return { r: parts[0], g: parts[1], b: parts[2], a: Number.isFinite(alpha) ? alpha : 1 };
|
|
1364
|
-
};
|
|
1365
|
-
const hex = (color) => color ? `#${[color.r, color.g, color.b].map((part) => Math.round(part).toString(16).padStart(2, "0")).join("")}` : null;
|
|
1366
|
-
const luminance = (color) => {
|
|
1367
|
-
const channel = (value) => {
|
|
1368
|
-
const normalized = value / 255;
|
|
1369
|
-
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
|
1370
|
-
};
|
|
1371
|
-
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
|
|
1372
|
-
};
|
|
1373
|
-
const contrast = (a, b) => {
|
|
1374
|
-
if (!a || !b) return null;
|
|
1375
|
-
const light = Math.max(luminance(a), luminance(b));
|
|
1376
|
-
const dark = Math.min(luminance(a), luminance(b));
|
|
1377
|
-
return Math.round(((light + 0.05) / (dark + 0.05)) * 100) / 100;
|
|
1378
|
-
};
|
|
1379
|
-
const effectiveBackground = (element) => {
|
|
1380
|
-
let current = element;
|
|
1381
|
-
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
|
1382
|
-
const style = getComputedStyle(current);
|
|
1383
|
-
const color = parseColor(style.backgroundColor);
|
|
1384
|
-
if (color && color.a > 0.05) {
|
|
1385
|
-
return { color, source: current === element ? "element" : current.tagName.toLowerCase() };
|
|
1386
|
-
}
|
|
1387
|
-
current = current.parentElement;
|
|
1388
|
-
}
|
|
1389
|
-
return { color: { r: 255, g: 255, b: 255, a: 1 }, source: "assumed_canvas" };
|
|
1390
|
-
};
|
|
1391
1358
|
const isVisible = (element) => {
|
|
1392
1359
|
const rect = element.getBoundingClientRect();
|
|
1393
1360
|
const style = getComputedStyle(element);
|
|
@@ -1440,56 +1407,124 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1440
1407
|
// per-element detail for the listed candidates only.
|
|
1441
1408
|
const ignoredAttributes = Array.from(new Set(visibleElements.flatMap(ignoredAttributesOn))).sort();
|
|
1442
1409
|
|
|
1443
|
-
|
|
1410
|
+
// A route candidate's text, read through the shared contrast helper: each
|
|
1411
|
+
// text-bearing element inside it is compared, unrounded, with the
|
|
1412
|
+
// requirement for its own size. An element with a review trigger (or with
|
|
1413
|
+
// no ratio to compare) is a review member and is not compared; an element
|
|
1414
|
+
// whose control is disabled or loading has no colours read. Text whose
|
|
1415
|
+
// control is disabled is inactive: it is not rendered text the check can
|
|
1416
|
+
// read, so it never counts toward text_rendered or toward a pass. A
|
|
1417
|
+
// disabled candidate is not measured at all, whatever text it generates,
|
|
1418
|
+
// and never counts toward a pass. In an enabled candidate, text an element
|
|
1419
|
+
// inside it generates through ::before or ::after (generatedTextImpl) is
|
|
1420
|
+
// never measured, so the candidate is reviewed, never passed on its other
|
|
1421
|
+
// text.
|
|
1422
|
+
const measureCandidate = (element) => {
|
|
1423
|
+
if (element.matches(":disabled, [aria-disabled=\"true\"]")) {
|
|
1424
|
+
return { disabled: true, text_rendered: null, control_loading: null, contrast_ratio: null, elements: [], below: false, review: false, measured: false, inactive: false };
|
|
1425
|
+
}
|
|
1426
|
+
// Every element the page renders inside the candidate: its own subtree,
|
|
1427
|
+
// open shadow roots included.
|
|
1428
|
+
const subtree = toolkit.flatSubtree(element);
|
|
1429
|
+
const reads = subtree
|
|
1430
|
+
.filter((node) => toolkit.isTextBearing(node, window))
|
|
1431
|
+
.map((node) => toolkit.measureTextElement(node, window));
|
|
1432
|
+
const compared = [];
|
|
1433
|
+
let review = subtree.some((node) => generatedTextImpl(node, window));
|
|
1434
|
+
for (const read of reads) {
|
|
1435
|
+
if (read.disabled || read.control_loading) continue;
|
|
1436
|
+
if (read.review_reason !== null || typeof read.ratio !== "number") review = true;
|
|
1437
|
+
else compared.push(read);
|
|
1438
|
+
}
|
|
1439
|
+
const ratios = compared.map((read) => read.ratio);
|
|
1440
|
+
return {
|
|
1441
|
+
disabled: false,
|
|
1442
|
+
text_rendered: reads.some((read) => !read.disabled),
|
|
1443
|
+
control_loading: reads.some((read) => read.control_loading),
|
|
1444
|
+
contrast_ratio: ratios.length ? toolkit.displayRatio(Math.min(...ratios)) : null,
|
|
1445
|
+
elements: reads.map((read) => ({
|
|
1446
|
+
selector_path: read.selector_path,
|
|
1447
|
+
contrast_ratio_exact: read.ratio,
|
|
1448
|
+
required_ratio: read.required,
|
|
1449
|
+
size_class: read.size_class,
|
|
1450
|
+
review_reason: read.review_reason,
|
|
1451
|
+
})),
|
|
1452
|
+
below: compared.some((read) => !toolkit.meetsRequirement(read.ratio, read.required)),
|
|
1453
|
+
review,
|
|
1454
|
+
measured: compared.length > 0,
|
|
1455
|
+
inactive: reads.some((read) => read.disabled),
|
|
1456
|
+
};
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
// Route candidates carry their measurement; other rows are listed for the
|
|
1460
|
+
// route-recognition view only and are not measured.
|
|
1461
|
+
const verdicts = new Map();
|
|
1462
|
+
const rows = visibleElements
|
|
1444
1463
|
.map((element) => {
|
|
1445
1464
|
const rect = element.getBoundingClientRect();
|
|
1446
|
-
const style = getComputedStyle(element);
|
|
1447
|
-
const fg = parseColor(style.color);
|
|
1448
|
-
const bg = effectiveBackground(element);
|
|
1449
|
-
const ratio = contrast(fg, bg.color);
|
|
1450
1465
|
const href = hrefFor(element);
|
|
1451
1466
|
const label = trim(element.innerText || element.textContent || element.getAttribute("aria-label"));
|
|
1452
1467
|
return {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
readable: typeof ratio === "number" && ratio >= 4.5,
|
|
1465
|
-
size_ok: rect.width >= 40 && rect.height >= 20,
|
|
1468
|
+
element,
|
|
1469
|
+
row: {
|
|
1470
|
+
selector: selectorFor(element),
|
|
1471
|
+
text: label.slice(0, 120),
|
|
1472
|
+
href,
|
|
1473
|
+
route_matches: routeMatches(href),
|
|
1474
|
+
ignored_attributes: ignoredAttributesOn(element),
|
|
1475
|
+
width: Math.round(rect.width),
|
|
1476
|
+
height: Math.round(rect.height),
|
|
1477
|
+
size_ok: rect.width >= 40 && rect.height >= 20,
|
|
1478
|
+
},
|
|
1466
1479
|
};
|
|
1467
1480
|
})
|
|
1468
|
-
.filter((
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
return (b.contrast_ratio || 0) - (a.contrast_ratio || 0);
|
|
1481
|
+
.filter(({ row }) => row.text || row.href)
|
|
1482
|
+
.map(({ element, row }) => {
|
|
1483
|
+
if (!row.route_matches) return { ...row, disabled: null, text_rendered: null, control_loading: null, contrast_ratio: null, elements: null };
|
|
1484
|
+
const { below, review, measured, inactive, ...read } = measureCandidate(element);
|
|
1485
|
+
const candidate = { ...row, ...read };
|
|
1486
|
+
verdicts.set(candidate, { below, review, measured, inactive });
|
|
1487
|
+
return candidate;
|
|
1476
1488
|
});
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1489
|
+
|
|
1490
|
+
// Every route candidate counts, in document order; none is preferred.
|
|
1491
|
+
// The page outcome is the first that applies. An enabled candidate whose
|
|
1492
|
+
// only text is inactive (its control is disabled) has no rendered text;
|
|
1493
|
+
// cta_disabled is only for every candidate disabled. The page passes only
|
|
1494
|
+
// when every enabled candidate had at least one element compared, so text
|
|
1495
|
+
// that was skipped never stands in for a measurement.
|
|
1496
|
+
const routeCandidates = rows.filter((candidate) => candidate.route_matches);
|
|
1497
|
+
const enabled = routeCandidates.filter((candidate) => !candidate.disabled);
|
|
1498
|
+
const measurability = routeCandidates.length ? toolkit.documentMeasurability(document) : { measurable: true };
|
|
1499
|
+
const reason = !routeCandidates.length
|
|
1500
|
+
? "missing_route_cta"
|
|
1501
|
+
: !measurability.measurable
|
|
1502
|
+
? measurability.reason
|
|
1503
|
+
: enabled.some((candidate) => verdicts.get(candidate).below)
|
|
1504
|
+
? "low_contrast"
|
|
1505
|
+
: !routeCandidates.some((candidate) => candidate.size_ok)
|
|
1506
|
+
? "cta_too_small"
|
|
1507
|
+
: enabled.some((candidate) => !candidate.text_rendered)
|
|
1508
|
+
? "text_not_rendered"
|
|
1509
|
+
: enabled.some((candidate) => candidate.control_loading)
|
|
1510
|
+
? "control_loading"
|
|
1511
|
+
: enabled.some((candidate) => verdicts.get(candidate).review)
|
|
1512
|
+
? "contrast_review"
|
|
1513
|
+
: !enabled.length || enabled.some((candidate) => !verdicts.get(candidate).measured)
|
|
1514
|
+
? "cta_disabled"
|
|
1515
|
+
: "ok";
|
|
1516
|
+
|
|
1517
|
+
// Rows are capped at eight, except that every route candidate is listed.
|
|
1518
|
+
const listed = [];
|
|
1519
|
+
for (const row of rows) if (row.route_matches || listed.length < 8) listed.push(row);
|
|
1486
1520
|
|
|
1487
1521
|
return {
|
|
1488
|
-
ok,
|
|
1522
|
+
ok: reason === "ok",
|
|
1489
1523
|
reason,
|
|
1490
1524
|
expected_url: routeUrl,
|
|
1491
|
-
|
|
1492
|
-
|
|
1525
|
+
// The first route candidate in document order, for display only.
|
|
1526
|
+
primary: routeCandidates[0] || null,
|
|
1527
|
+
candidates: listed,
|
|
1493
1528
|
// Every route-shaped attribute seen on a visible CTA-shaped element and
|
|
1494
1529
|
// not consulted (see above), so a missing-route verdict on a page spelled
|
|
1495
1530
|
// that way reads as a vocabulary gap, not as a removed CTA.
|
|
@@ -1497,8 +1532,20 @@ function inspectPrimaryCtaScript({ routeUrl, ctaSelector, cartEntrySelector, car
|
|
|
1497
1532
|
};
|
|
1498
1533
|
}
|
|
1499
1534
|
|
|
1535
|
+
// The outcomes the page itself earns as a failure. Every other outcome that
|
|
1536
|
+
// is not a pass could not be observed completely (the page was not
|
|
1537
|
+
// measurable, text or a control was not ready, a review trigger, every CTA
|
|
1538
|
+
// disabled, or an inspection the page rejected): manual review, never a pass
|
|
1539
|
+
// and never a contrast failure.
|
|
1540
|
+
const PRIMARY_CTA_FAIL_REASONS = Object.freeze([
|
|
1541
|
+
"missing_route_cta",
|
|
1542
|
+
"low_contrast",
|
|
1543
|
+
"cta_too_small",
|
|
1544
|
+
]);
|
|
1545
|
+
|
|
1500
1546
|
function primaryCtaAssertionFromEvidence(page, evidence) {
|
|
1501
1547
|
const ok = evidence?.ok === true;
|
|
1548
|
+
const review = !ok && !PRIMARY_CTA_FAIL_REASONS.includes(evidence?.reason);
|
|
1502
1549
|
const ignored = Array.isArray(evidence?.ignored_attributes) ? evidence.ignored_attributes.filter(Boolean) : [];
|
|
1503
1550
|
// Named on every verdict the page earns, passing or not: a passing page
|
|
1504
1551
|
// spelled with an undeclared route attribute is still one an operator
|
|
@@ -1509,7 +1556,7 @@ function primaryCtaAssertionFromEvidence(page, evidence) {
|
|
|
1509
1556
|
id: `browser-primary-cta:${page.page_id}`,
|
|
1510
1557
|
family: "browser-runtime",
|
|
1511
1558
|
page,
|
|
1512
|
-
status: ok ? STATUS.PASS : STATUS.FAIL,
|
|
1559
|
+
status: ok ? STATUS.PASS : review ? STATUS.MANUAL_REVIEW : STATUS.FAIL,
|
|
1513
1560
|
severity: ok ? undefined : SEVERITY.WARN,
|
|
1514
1561
|
expected: "visible readable primary CTA linked to the expected next route",
|
|
1515
1562
|
actual: ok
|
package/src/qa-node.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch
|
|
|
2
2
|
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD, starterResidueIsExpected } from "./local-preview-policy.mjs";
|
|
3
3
|
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
|
|
4
4
|
import { shellToken } from "./shell-token.mjs";
|
|
5
|
+
import { currentBriefMaterial } from "./input-currency.mjs";
|
|
5
6
|
import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
|
|
6
7
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
7
8
|
import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
|
|
@@ -468,6 +469,9 @@ async function resolveQaInputs(args, {
|
|
|
468
469
|
routeRootNote: routeRootNotes[0] || null,
|
|
469
470
|
});
|
|
470
471
|
const specHash = computeSpecHash(rawSpec);
|
|
472
|
+
// The brief material this run is judged against, bound at run start beside
|
|
473
|
+
// the spec hash: the QA stage write stamps it, not the material at write time.
|
|
474
|
+
const sourceBriefMaterial = packet ? currentBriefMaterial({ packet, packetPath }) : null;
|
|
471
475
|
const templateFamily = stringArg(packet?.assembly?.template_family)
|
|
472
476
|
|| stringArg(normalized?.spec_identity?.preferred_template_family)
|
|
473
477
|
|| stringArg(normalized?.campaign?.preferred_template_family)
|
|
@@ -524,6 +528,7 @@ async function resolveQaInputs(args, {
|
|
|
524
528
|
spec: normalized,
|
|
525
529
|
specVersion: String(rawSpec.schema_version || rawSpec.schemaVersion || "unknown"),
|
|
526
530
|
specHash,
|
|
531
|
+
sourceBriefMaterial,
|
|
527
532
|
templateFamily,
|
|
528
533
|
commerceStructureContract,
|
|
529
534
|
topologies: qaScope.topologies,
|
|
@@ -583,7 +588,7 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
583
588
|
}
|
|
584
589
|
if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
|
|
585
590
|
|| (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
|
|
586
|
-
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash.
|
|
591
|
+
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Run `record spec` after a material revision (it keeps stage history); do not reuse foreign or stale proof.");
|
|
587
592
|
}
|
|
588
593
|
const checkpointGates = [
|
|
589
594
|
evaluatePageKitStoreProfile({
|
|
@@ -1585,6 +1590,12 @@ function reportPathField(resolved) {
|
|
|
1585
1590
|
return reportPath ? { report_path: reportPath } : {};
|
|
1586
1591
|
}
|
|
1587
1592
|
|
|
1593
|
+
// The verdict with the brief material qa run bound at run start, when the
|
|
1594
|
+
// run had a packet to bind it from.
|
|
1595
|
+
function withSourceBriefMaterial(verdict, sourceBriefMaterial) {
|
|
1596
|
+
return sourceBriefMaterial ? { ...verdict, source_brief_material: sourceBriefMaterial } : verdict;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1588
1599
|
function resolvePayload(resolved, { routeProbe = null } = {}) {
|
|
1589
1600
|
const entryUrls = deriveEntryUrls(resolved.topologies);
|
|
1590
1601
|
const pageUrls = derivePageUrls(resolved.topologies);
|
|
@@ -2598,7 +2609,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2598
2609
|
// The verdict as it is persisted: the local file, the committed sidecar and
|
|
2599
2610
|
// the published copy all read this one projection (no URL query in any
|
|
2600
2611
|
// string or key), whichever runner, return path or field produced a value.
|
|
2601
|
-
const verdict = redactPersisted(createVerdict({
|
|
2612
|
+
const verdict = redactPersisted(withSourceBriefMaterial(createVerdict({
|
|
2602
2613
|
runId,
|
|
2603
2614
|
mapId: resolved.mapId,
|
|
2604
2615
|
localSpecId: resolved.localSpecId,
|
|
@@ -2619,7 +2630,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2619
2630
|
commercial,
|
|
2620
2631
|
causeSummary,
|
|
2621
2632
|
browser,
|
|
2622
|
-
}));
|
|
2633
|
+
}), resolved.sourceBriefMaterial));
|
|
2623
2634
|
|
|
2624
2635
|
const validationErrors = validateVerdict(verdict);
|
|
2625
2636
|
if (validationErrors.length) throw new Error(`QA verdict failed local validation:\n- ${validationErrors.join("\n- ")}`);
|
package/src/qc-accept.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { isNamedHuman, validateWaiverAttribution } from "./checkpoint-waiver.mjs
|
|
|
14
14
|
import { DOCTOR_SIDECAR_SCHEMA } from "./doctor-sidecar.mjs";
|
|
15
15
|
import { cmd } from "./install-invocation.mjs";
|
|
16
16
|
import { canonicalJson } from "./polish-capture.mjs";
|
|
17
|
+
import { QC_CHECK_REGISTRY } from "./qc-check-registry.mjs";
|
|
17
18
|
import { QC_LEGS, QC_REASON, fingerprint12, qcResultRef } from "./qc-results.mjs";
|
|
18
19
|
import { shellToken } from "./shell-token.mjs";
|
|
19
20
|
|
|
@@ -22,7 +23,18 @@ export const QC_ACCEPT_SCOPE = "qc_accept";
|
|
|
22
23
|
export const QC_ACCEPT_RECORDER = "campaigns-os checkpoint accept";
|
|
23
24
|
export const QC_ACCEPT_STATUSES = Object.freeze(["active", "lapsed", "orphaned", "expired", "inert"]);
|
|
24
25
|
export const QC_DISPOSITION = Object.freeze({ OPEN: "open", OPERATOR_ACCEPTED: "operator_accepted" });
|
|
25
|
-
|
|
26
|
+
// The evidence an accept's measurement came from: the doctor sidecar, the
|
|
27
|
+
// full QA verdict, or, on the Polish leg, the package-owned record the
|
|
28
|
+
// accepted check is read from (QC_CHECK_REGISTRY `record`).
|
|
29
|
+
const QC_LEG_SOURCES = Object.freeze({ doctor: "doctor_sidecar", qa: "qa_full_verdict" });
|
|
30
|
+
export const QC_POLISH_RECORD_SOURCES = Object.freeze({ media_weight: "polish_media_weight", readability: "polish_readability" });
|
|
31
|
+
|
|
32
|
+
// Null for a Polish check with no record, or a leg with no source.
|
|
33
|
+
export function measuredSourceFor(leg, check) {
|
|
34
|
+
if (leg !== "polish") return Object.hasOwn(QC_LEG_SOURCES, leg) ? QC_LEG_SOURCES[leg] : null;
|
|
35
|
+
const entry = Object.hasOwn(QC_CHECK_REGISTRY, check) ? QC_CHECK_REGISTRY[check] : null;
|
|
36
|
+
return entry?.leg === "polish" && Object.hasOwn(QC_POLISH_RECORD_SOURCES, entry.record) ? QC_POLISH_RECORD_SOURCES[entry.record] : null;
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
// The command's closed refusal list.
|
|
28
40
|
export const QC_ACCEPT_REFUSALS = Object.freeze({
|
|
@@ -100,7 +112,7 @@ export function createQcAccept(result, { measuredAt, attribution }) {
|
|
|
100
112
|
state_fingerprint: result.state_fingerprint,
|
|
101
113
|
result_at_accept: "warning",
|
|
102
114
|
measured_at: measuredAt,
|
|
103
|
-
measured_source:
|
|
115
|
+
measured_source: measuredSourceFor(result.leg, result.check),
|
|
104
116
|
...attribution,
|
|
105
117
|
recorded_by: QC_ACCEPT_RECORDER,
|
|
106
118
|
};
|
|
@@ -119,7 +131,8 @@ function malformed(record) {
|
|
|
119
131
|
&& FINGERPRINT.test(record.state_fingerprint || "")
|
|
120
132
|
&& record.result_at_accept === "warning"
|
|
121
133
|
&& validTime(record.measured_at)
|
|
122
|
-
&& record.measured_source
|
|
134
|
+
&& isNonEmptyString(record.measured_source)
|
|
135
|
+
&& record.measured_source === measuredSourceFor(record.leg, record.check)
|
|
123
136
|
&& typeof record.reason === "string"
|
|
124
137
|
&& typeof record.accepted_by === "string"
|
|
125
138
|
&& typeof record.accepted_at === "string"
|
|
@@ -291,7 +304,29 @@ export function planQcAccepts({ refs, results, sidecarPath, now, attribution })
|
|
|
291
304
|
const LEG_ORDER = Object.freeze({ doctor: 0, polish: 1, qa: 2 });
|
|
292
305
|
const compareText = (a, b) => String(a ?? "").localeCompare(String(b ?? ""));
|
|
293
306
|
|
|
307
|
+
// A readability colour pair reads as its roles, colours, ratio and
|
|
308
|
+
// requirement: `<roles>: <fg8> on <bg8>, <ratio>:1, needs <required>:1
|
|
309
|
+
// (<size_class> text)`, from the row's observation (the lowest ratio of the
|
|
310
|
+
// rows given). Null for any other row.
|
|
311
|
+
function readabilityPairSummary(rows) {
|
|
312
|
+
const pairs = rows.map((row) => (row?.check === "readability.contrast" && String(row.subject?.key ?? "").startsWith("pair:") && isPlainObject(row.observation) ? row.observation : null));
|
|
313
|
+
if (!pairs.length || pairs.some((observation) => observation === null)) return null;
|
|
314
|
+
const roles = [...new Set(pairs.flatMap((observation) => (Array.isArray(observation.roles) ? observation.roles : [])))].sort(compareText);
|
|
315
|
+
const lowest = pairs.reduce((low, observation) => (observation.ratio < low.ratio ? observation : low));
|
|
316
|
+
return `${roles.join(", ")}: ${lowest.fg8} on ${lowest.bg8}, ${lowest.display_ratio}:1, needs ${lowest.required}:1 (${lowest.size_class} text)`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The crop path of a readability review row: its first member's crop.
|
|
320
|
+
// Undefined for a review row with no crop, and for any other row.
|
|
321
|
+
function readabilityReviewCrop(row) {
|
|
322
|
+
if (row?.check !== "readability.contrast" || row.result !== "review") return undefined;
|
|
323
|
+
const members = Array.isArray(row.observation?.members) ? row.observation.members : [];
|
|
324
|
+
const ref = members.find((member) => isPlainObject(member?.crop_ref))?.crop_ref;
|
|
325
|
+
return typeof ref?.path === "string" ? ref.path : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
294
328
|
function handoffEntry(row) {
|
|
329
|
+
const crop = readabilityReviewCrop(row);
|
|
295
330
|
return {
|
|
296
331
|
result_ref: qcResultRef(row),
|
|
297
332
|
check: row.check,
|
|
@@ -303,7 +338,8 @@ function handoffEntry(row) {
|
|
|
303
338
|
reason_code: row.reason_code,
|
|
304
339
|
accept_eligible: row.accept_eligible === true,
|
|
305
340
|
members: Array.isArray(row.members) ? row.members : [],
|
|
306
|
-
summary: `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
|
|
341
|
+
summary: readabilityPairSummary([row]) ?? `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
|
|
342
|
+
...(crop === undefined ? {} : { crop }),
|
|
307
343
|
};
|
|
308
344
|
}
|
|
309
345
|
|
|
@@ -315,7 +351,7 @@ const byCheckKeyPage = (a, b) => compareText(a.check, b.check) || compareText(a.
|
|
|
315
351
|
// first page's fields, `pages`, and `results`, which keeps every page's result
|
|
316
352
|
// with its own ref, accept eligibility and members, so every member stays
|
|
317
353
|
// visible. The entry is accept-eligible only when every result in it is.
|
|
318
|
-
function groupOpenWarnings(entries) {
|
|
354
|
+
function groupOpenWarnings(entries, rows = []) {
|
|
319
355
|
const groups = new Map();
|
|
320
356
|
for (const entry of [...entries].sort(byCheckKeyPage)) {
|
|
321
357
|
const key = JSON.stringify([entry.check, entry.key]);
|
|
@@ -330,7 +366,7 @@ function groupOpenWarnings(entries) {
|
|
|
330
366
|
pages,
|
|
331
367
|
accept_eligible: results.every((entry) => entry.accept_eligible),
|
|
332
368
|
summary: results.length > 1
|
|
333
|
-
? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
|
|
369
|
+
? readabilityPairSummary(results.map((entry) => rows.find((row) => qcResultRef(row) === entry.result_ref))) ?? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
|
|
334
370
|
: lead.summary,
|
|
335
371
|
results,
|
|
336
372
|
};
|
|
@@ -401,7 +437,7 @@ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date
|
|
|
401
437
|
const coverageEntries = [...grouped.values(), ...coverage]
|
|
402
438
|
.map((entry) => ({ ...entry, pages: [...entry.pages].sort(compareText) }))
|
|
403
439
|
.sort((a, b) => (LEG_ORDER[a.leg] ?? 9) - (LEG_ORDER[b.leg] ?? 9) || compareText(a.check, b.check) || compareText(a.reason_code, b.reason_code) || compareText(a.result, b.result));
|
|
404
|
-
const openGroups = groupOpenWarnings(open);
|
|
440
|
+
const openGroups = groupOpenWarnings(open, results);
|
|
405
441
|
review.sort(byCheckKeyPage);
|
|
406
442
|
lapsed.sort(byCheckKeyPage);
|
|
407
443
|
accepted.sort(byCheckKeyPage);
|
|
@@ -435,7 +471,7 @@ export function qcHandoffTextLines(handoff) {
|
|
|
435
471
|
section("Open warnings", handoff.open, (entry) => (entry.results?.length > 1
|
|
436
472
|
? [entry.summary, ...entry.results.map((result) => ` ${openLine(result)}`)]
|
|
437
473
|
: openLine(entry)));
|
|
438
|
-
section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${members(entry)}`);
|
|
474
|
+
section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${entry.crop ? ` crop: ${entry.crop}` : ""}${members(entry)}`);
|
|
439
475
|
section("Lapsed accepts", handoff.lapsed, (entry) => `${entry.result_ref} ${entry.summary}; accepted by ${entry.accepted_by} at ${entry.accepted_at}; lapsed: ${entry.why}`);
|
|
440
476
|
section("Coverage", handoff.coverage, (entry) => `${entry.leg} ${entry.check}: ${entry.result} (${entry.reason_code})${entry.count ? ` x${entry.count}` : ""}${entry.pages.length ? ` on ${entry.pages.join(", ")}` : ""}`);
|
|
441
477
|
section("Accepted", handoff.accepted, (entry) => `${entry.result_ref} accepted by ${entry.accepted_by} at ${entry.accepted_at}: ${entry.reason}${members(entry)}`);
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// The one map from a QC check id to the check module that re-derives its
|
|
2
|
-
// results. The QC readers
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// results. The QC readers (src/qc-results.mjs) reach every check's rules
|
|
3
|
+
// through here. A QA check lands by adding its module; a Polish check reads
|
|
4
|
+
// one package-owned visual_review record, named by `record`, and each record
|
|
5
|
+
// has its own reader in the Polish dispatch (POLISH_RECORDS), so a check for
|
|
6
|
+
// a new record also adds that reader.
|
|
5
7
|
//
|
|
6
8
|
// Each entry names a module specifier, imported lazily and relative to this
|
|
7
9
|
// file, and the export the reader calls:
|
|
8
10
|
// - QA checks: `rederiveQcResult(observation)` returns a Derived result
|
|
9
11
|
// ({check, subject, result, reason_code, members, accept_eligible, coverage,
|
|
10
12
|
// state}) or null when the observation cannot be re-derived.
|
|
11
|
-
// - Polish checks:
|
|
12
|
-
// evaluate(cell, thresholds) => Derived[]}
|
|
13
|
+
// - Polish checks: the record's rules, {thresholds, vocabulary,
|
|
14
|
+
// evaluate(cell, thresholds) => Derived[]} (MEDIA_WEIGHT_QC_RULES for
|
|
15
|
+
// media_weight; READABILITY_QC_RULES for readability, which also carries
|
|
16
|
+
// the record's fixed producer values, limits and route set).
|
|
13
17
|
// - Doctor checks recompute from the built HTML on every read and are called
|
|
14
18
|
// by doctor itself; their entries name the evaluator for completeness.
|
|
15
19
|
//
|
|
@@ -23,11 +27,13 @@ export const QC_CHECK_REGISTRY = Object.freeze({
|
|
|
23
27
|
// Content params
|
|
24
28
|
content_param: Object.freeze({ leg: "qa", unit: "1.2", module: "./qa-content-params.mjs", rederive: "rederiveQcResult" }),
|
|
25
29
|
// Media weight and oversizing
|
|
26
|
-
"media.weight": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES" }),
|
|
27
|
-
"media.oversize": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES" }),
|
|
30
|
+
"media.weight": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES", record: "media_weight" }),
|
|
31
|
+
"media.oversize": Object.freeze({ leg: "polish", unit: "1.3", module: "./polish-media-weight.mjs", rederive: "MEDIA_WEIGHT_QC_RULES", record: "media_weight" }),
|
|
28
32
|
// Policy links
|
|
29
33
|
"policy.presence": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
|
|
30
34
|
"policy.availability": Object.freeze({ leg: "qa", unit: "1.4", module: "./qa-policy-links.mjs", rederive: "rederiveQcResult" }),
|
|
35
|
+
// Text readability
|
|
36
|
+
"readability.contrast": Object.freeze({ leg: "polish", unit: "2.4", module: "./polish-readability.mjs", rederive: "READABILITY_QC_RULES", record: "readability" }),
|
|
31
37
|
// Cart placeholders
|
|
32
38
|
cart_placeholders: Object.freeze({ leg: "doctor", unit: "1.5", module: "./cart-placeholders.mjs", rederive: "evaluateCartPlaceholders" }),
|
|
33
39
|
// Built-output smoke checks
|
|
@@ -35,6 +41,8 @@ export const QC_CHECK_REGISTRY = Object.freeze({
|
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
export const qcChecksForLeg = (leg) => Object.keys(QC_CHECK_REGISTRY).filter((check) => QC_CHECK_REGISTRY[check].leg === leg);
|
|
44
|
+
// The Polish checks read from one visual_review record.
|
|
45
|
+
export const qcChecksForRecord = (record) => Object.keys(QC_CHECK_REGISTRY).filter((check) => QC_CHECK_REGISTRY[check].leg === "polish" && QC_CHECK_REGISTRY[check].record === record);
|
|
38
46
|
|
|
39
47
|
// A module "does not exist yet" only when Node reports ERR_MODULE_NOT_FOUND
|
|
40
48
|
// for that exact specifier. A missing transitive import names another URL,
|