@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
|
@@ -22,6 +22,9 @@ import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
|
22
22
|
|
|
23
23
|
export const LOCAL_PREVIEW_POLICY = "local_preview";
|
|
24
24
|
export const HOSTED_TEMPLATE_PREVIEW_POLICY = "hosted_template_preview";
|
|
25
|
+
// A page-only change since Polish last reviewed the campaign (#720): Polish
|
|
26
|
+
// is advisory for it, on any target, so its stale evidence is carried.
|
|
27
|
+
export const PAGE_ONLY_CHANGE_POLICY = "page_only_change";
|
|
25
28
|
export const CARRIED_FORWARD = "carried_forward";
|
|
26
29
|
|
|
27
30
|
// The checks carried forward, one by one. Each is evidence the campaign does
|
|
@@ -136,7 +139,9 @@ export function carried(gate, policy = LOCAL_PREVIEW_POLICY, packet = null) {
|
|
|
136
139
|
...(policy === HOSTED_TEMPLATE_PREVIEW_POLICY
|
|
137
140
|
? { required_actions: [hostedTemplateQaAction(previewUrl)] }
|
|
138
141
|
: {}),
|
|
139
|
-
|
|
142
|
+
// A page-only carry stands on a review of an older build, so its evidence
|
|
143
|
+
// is stale, except where no Polish was ever recorded.
|
|
144
|
+
carried_forward: { policy, from_status: gate.status, evidence: policy === PAGE_ONLY_CHANGE_POLICY && gate.code !== "polish.evidence_missing" ? "stale" : "missing" },
|
|
140
145
|
};
|
|
141
146
|
}
|
|
142
147
|
|
|
@@ -156,13 +161,38 @@ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl =
|
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
// The polish gate folds the checkpoint in, so it is carried forward only when
|
|
159
|
-
// whatever it owns from the checkpoint was carried forward too.
|
|
164
|
+
// whatever it owns from the checkpoint was carried forward too. Advisory
|
|
165
|
+
// Polish for a page-only change is never a ladder step.
|
|
160
166
|
export function polishCarriedForwardForLadder(report, gate) {
|
|
167
|
+
if (gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) return true;
|
|
161
168
|
return gate?.status === CARRIED_FORWARD
|
|
162
169
|
&& !report?.theme?.waiver
|
|
163
170
|
&& !report?.stages?.polish?.evidence?.visual_review?.page_load;
|
|
164
171
|
}
|
|
165
172
|
|
|
173
|
+
// The Polish gate codes and checkpoint codes that mean "evidence for an older
|
|
174
|
+
// build": the only blocks a page-only change carries. A Polish recorded as
|
|
175
|
+
// blocked, self-certified or incomplete stays a block.
|
|
176
|
+
const PAGE_ONLY_POLISH_CODES = Object.freeze(new Set(["polish.evidence_missing", "polish.stale", "polish.output_drift"]));
|
|
177
|
+
const PAGE_ONLY_CHECKPOINT_CODES = Object.freeze(new Set(["polish.hidden_eager_media.capture_stale"]));
|
|
178
|
+
|
|
179
|
+
export function isPageOnlyCarry(gate) {
|
|
180
|
+
return gate?.status === CARRIED_FORWARD && gate.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// `debt` is assessEvidenceDebt's result for the same packet and report.
|
|
184
|
+
export function applyPageOnlyChangeToCheckpoint(gate, debt) {
|
|
185
|
+
if (gate?.status !== "blocked" || debt?.polish?.advisory !== true || !PAGE_ONLY_CHECKPOINT_CODES.has(gate.code)) return gate;
|
|
186
|
+
return carried(gate, PAGE_ONLY_CHANGE_POLICY);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function applyPageOnlyChangeToPolishGate(gate, { debt, checkpointGate = null } = {}) {
|
|
190
|
+
if (gate?.status !== "blocked" || debt?.polish?.advisory !== true) return gate;
|
|
191
|
+
if (gate.owned_checkpoint_only) return isPageOnlyCarry(checkpointGate) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
|
|
192
|
+
if (gate.owned_checkpoint_status === "blocked" && !isPageOnlyCarry(checkpointGate) && checkpointGate?.status !== CARRIED_FORWARD) return gate;
|
|
193
|
+
return PAGE_ONLY_POLISH_CODES.has(gate.code) ? carried(gate, PAGE_ONLY_CHANGE_POLICY) : gate;
|
|
194
|
+
}
|
|
195
|
+
|
|
166
196
|
export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
|
|
167
197
|
if (gate?.status !== "blocked") return gate;
|
|
168
198
|
// A template-stock map has no route that capture can measure, even when
|
|
@@ -189,6 +219,9 @@ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } =
|
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
export function carriedForwardMessage(gate) {
|
|
222
|
+
if (gate?.carried_forward?.policy === PAGE_ONLY_CHANGE_POLICY) {
|
|
223
|
+
return `Advisory for a page-only change: ${gate.reason} The change since Polish last reviewed this campaign touches only page copy, images or styling, so Polish is reported and not required; run it when the change needs a visual review.`;
|
|
224
|
+
}
|
|
192
225
|
if (gate?.carried_forward?.policy === HOSTED_TEMPLATE_PREVIEW_POLICY) {
|
|
193
226
|
return `Carried forward on the hosted preview: ${gate.reason} This evidence is missing, not passed; production QA still requires it.`;
|
|
194
227
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Doctor's advisory read of the starter-template vendor keys (#689): whether
|
|
2
|
+
// the vendors the CampaignSpec declares under analytics.vendors and the
|
|
3
|
+
// vendors the target's _data/campaigns.json entry switches on agree.
|
|
4
|
+
//
|
|
5
|
+
// A vendor adapter loads when its id key in the entry is non-empty (absent or
|
|
6
|
+
// empty is off), so the entry is what ships. The spec is what the Map says
|
|
7
|
+
// should ship, and spec derive mirrors the id from the entry back into it.
|
|
8
|
+
// Every finding is a warning: a vendor that is off, on by accident or pointed
|
|
9
|
+
// at a placeholder account loses or pollutes attribution, but never an order,
|
|
10
|
+
// and the operator may have a reason. Read-only: nothing is written.
|
|
11
|
+
import { ANALYTICS_VENDORS, isPlaceholderVendorId } from "../campaign-spec/dist/index.js";
|
|
12
|
+
import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
|
|
13
|
+
import { SPEC_DERIVE_COMMAND } from "./page-kit-sdk-version.mjs";
|
|
14
|
+
|
|
15
|
+
export const PAGE_KIT_ANALYTICS_VENDORS_SCOPE = "page_kit.analytics_vendors";
|
|
16
|
+
|
|
17
|
+
function isPlainObject(value) {
|
|
18
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The analytics kill switch the Map Builder honours: with it set, the spec
|
|
22
|
+
// compiler writes no vendor key, so the spec declares no vendor.
|
|
23
|
+
function analyticsDisabled(analytics) {
|
|
24
|
+
return isPlainObject(analytics) && (analytics.enabled === false || analytics.mode === "disabled");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The template gate is `campaign.<key> and campaign.<key> != ""`: any value
|
|
28
|
+
// but an absent, null, false or blank one switches the vendor on.
|
|
29
|
+
function keySwitchesVendorOn(value) {
|
|
30
|
+
if (value === undefined || value === null || value === false) return false;
|
|
31
|
+
return typeof value !== "string" || value.trim() !== "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The vendors the spec declares, keyed by vendor: an object entry not turned
|
|
35
|
+
// off with enabled:false. A declared vendor with no id is still declared (the
|
|
36
|
+
// AnalyticsContractShape rule names the missing id).
|
|
37
|
+
function declaredVendors(analytics) {
|
|
38
|
+
const declared = new Map();
|
|
39
|
+
if (!isPlainObject(analytics) || analyticsDisabled(analytics) || !isPlainObject(analytics.vendors)) return declared;
|
|
40
|
+
for (const { key } of ANALYTICS_VENDORS) {
|
|
41
|
+
const vendor = analytics.vendors[key];
|
|
42
|
+
if (isPlainObject(vendor) && vendor.enabled !== false) declared.set(key, vendor);
|
|
43
|
+
}
|
|
44
|
+
return declared;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Compare the spec's vendors with the target entry. `targetLoad` is the
|
|
48
|
+
// loadPageKitCampaignEntry result doctor already holds. Returns the vendors
|
|
49
|
+
// either side switches on and one finding per disagreement; findings name keys
|
|
50
|
+
// and vendors only, never a value read from campaigns.json.
|
|
51
|
+
export function evaluatePageKitAnalyticsVendors({ spec, targetLoad }) {
|
|
52
|
+
// Without a readable spec there is nothing to compare the entry with; the
|
|
53
|
+
// spec checks report why it is unreadable.
|
|
54
|
+
if (!isPlainObject(spec) || targetLoad?.status !== "ok" || !isPlainObject(targetLoad.entry)) {
|
|
55
|
+
return { scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE, status: "not_applicable", vendors: [], findings: [] };
|
|
56
|
+
}
|
|
57
|
+
const entry = targetLoad.entry;
|
|
58
|
+
const slug = targetLoad.public_route_slug;
|
|
59
|
+
const source = (key) => `${PAGE_KIT_CAMPAIGNS_REL_PATH}[${slug}].${key}`;
|
|
60
|
+
const analytics = spec?.analytics;
|
|
61
|
+
const disabled = analyticsDisabled(analytics);
|
|
62
|
+
const declared = declaredVendors(analytics);
|
|
63
|
+
const vendors = [];
|
|
64
|
+
const findings = [];
|
|
65
|
+
const finding = (code, def, message, repair) => findings.push({
|
|
66
|
+
code: `${PAGE_KIT_ANALYTICS_VENDORS_SCOPE}.${code}`,
|
|
67
|
+
vendor: def.key,
|
|
68
|
+
id_key: def.idKey,
|
|
69
|
+
message,
|
|
70
|
+
repair,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const def of ANALYTICS_VENDORS) {
|
|
74
|
+
const field = `analytics.vendors.${def.key}`;
|
|
75
|
+
const raw = Object.hasOwn(entry, def.idKey) ? entry[def.idKey] : undefined;
|
|
76
|
+
const on = keySwitchesVendorOn(raw);
|
|
77
|
+
const specVendor = declared.get(def.key);
|
|
78
|
+
if (on || specVendor) vendors.push(def.key);
|
|
79
|
+
if (specVendor && !on) {
|
|
80
|
+
finding(
|
|
81
|
+
"key_missing",
|
|
82
|
+
def,
|
|
83
|
+
`The CampaignSpec declares ${field}, but ${source(def.idKey)} is absent or empty, so the template never loads ${def.label} and it receives no events. Set the key (the Map's spec compiler writes it), or remove the vendor from the Map if the campaign should not send to ${def.label}.`,
|
|
84
|
+
{ kind: "edit", target: source(def.idKey) },
|
|
85
|
+
);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!specVendor && on) {
|
|
89
|
+
// spec derive can record an undeclared vendor; it cannot undo an
|
|
90
|
+
// authored "off", so that case is pointed at the Map instead.
|
|
91
|
+
const turnedOff = disabled
|
|
92
|
+
? "turns analytics off (analytics.enabled false or mode disabled)"
|
|
93
|
+
: isPlainObject(analytics?.vendors?.[def.key]) ? `turns ${field} off (enabled: false)` : null;
|
|
94
|
+
finding(
|
|
95
|
+
"undeclared",
|
|
96
|
+
def,
|
|
97
|
+
turnedOff
|
|
98
|
+
? `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec ${turnedOff}. The template loads ${def.label} on every page regardless. Clear the key if ${def.label} should be off, or turn it back on in the Map.`
|
|
99
|
+
: `${source(def.idKey)} switches ${def.label} on, but the CampaignSpec does not declare ${field}. The template loads ${def.label} on every page. Record it in the spec with \`${SPEC_DERIVE_COMMAND}\` (or in the Map), or clear the key if ${def.label} should be off.`,
|
|
100
|
+
turnedOff ? { kind: "edit", target: source(def.idKey) } : { kind: "command", command: SPEC_DERIVE_COMMAND },
|
|
101
|
+
);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!specVendor) continue;
|
|
105
|
+
const value = typeof raw === "string" ? raw.trim() : null;
|
|
106
|
+
if (value !== null && isPlaceholderVendorId(value)) {
|
|
107
|
+
finding(
|
|
108
|
+
"key_placeholder",
|
|
109
|
+
def,
|
|
110
|
+
`${source(def.idKey)} holds a placeholder, not a real ${def.label} id. The template loads ${def.label} for any non-empty value, so events go to an account that does not exist. Replace it with the merchant's id.`,
|
|
111
|
+
{ kind: "edit", target: source(def.idKey) },
|
|
112
|
+
);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const specId = typeof specVendor.id === "string" ? specVendor.id.trim() : "";
|
|
116
|
+
if (specId && value !== null && value !== specId) {
|
|
117
|
+
finding(
|
|
118
|
+
"id_mismatch",
|
|
119
|
+
def,
|
|
120
|
+
`${source(def.idKey)} and ${field}.id name different ${def.label} ids. The campaigns.json value is what ships; \`${SPEC_DERIVE_COMMAND}\` mirrors it into the spec, or correct the key if the spec is right.`,
|
|
121
|
+
{ kind: "command", command: SPEC_DERIVE_COMMAND },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
scope: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
|
|
128
|
+
status: findings.length ? "advisory" : "ok",
|
|
129
|
+
vendors,
|
|
130
|
+
findings,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Pure element parity comparison, implemented from Appendix A of the P contract.
|
|
2
|
+
export const PARITY_COMPARATOR_VERSION = "parity-compare/v1";
|
|
3
|
+
export const PARITY_TOLERANCES = Object.freeze({
|
|
4
|
+
position_px: 6, size_px: 6, gap_px: 6,
|
|
5
|
+
font_size_px: 0.6, line_height_px: 2, letter_spacing_px: 0.2,
|
|
6
|
+
color_channel: 3, fuzzy_jaccard: 0.6, missing_copy_min_chars: 4,
|
|
7
|
+
});
|
|
8
|
+
export const PARITY_PROBE_LIMITS = Object.freeze({
|
|
9
|
+
elements: 1500, text_chars: 500, page_text_chars: 262144, font_faces: 200, selector_path_chars: 2048,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const plain = (v) => v !== null && typeof v === "object"
|
|
13
|
+
&& (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
14
|
+
const finite = (v) => typeof v === "number" && Number.isFinite(v);
|
|
15
|
+
const nonnegative = (v) => finite(v) && v >= 0;
|
|
16
|
+
// Rule 0 (A-P4): box values stay within ±1e6 px so drift and distances stay finite.
|
|
17
|
+
const coordinate = (v) => finite(v) && Math.abs(v) <= 1e6;
|
|
18
|
+
const extent = (v) => coordinate(v) && v >= 0;
|
|
19
|
+
const integer = (v, min = 0) => Number.isInteger(v) && v >= min;
|
|
20
|
+
const boundedText = (v, max) => typeof v === "string" && [...v].length <= max;
|
|
21
|
+
const normalOr = (v, numberTest) => v === "normal" || numberTest(v);
|
|
22
|
+
const color = (v) => v === null || (Array.isArray(v) && v.length === 3
|
|
23
|
+
&& [...v].every((n) => finite(n) && n >= 0 && n <= 1));
|
|
24
|
+
|
|
25
|
+
export function isProbeOutput(v) {
|
|
26
|
+
if (!plain(v) || !plain(v.viewport) || !integer(v.viewport.width, 1) || !integer(v.viewport.height, 1)
|
|
27
|
+
|| !finite(v.dpr) || v.dpr <= 0 || !Array.isArray(v.elements)
|
|
28
|
+
|| v.elements.length > PARITY_PROBE_LIMITS.elements
|
|
29
|
+
|| !boundedText(v.page_text, PARITY_PROBE_LIMITS.page_text_chars)
|
|
30
|
+
|| !Array.isArray(v.font_faces) || v.font_faces.length > PARITY_PROBE_LIMITS.font_faces
|
|
31
|
+
|| !plain(v.coverage)) return false;
|
|
32
|
+
if (!["elements_capped", "time_capped", "page_text_truncated", "font_faces_capped"]
|
|
33
|
+
.every((k) => typeof v.coverage[k] === "boolean")
|
|
34
|
+
|| !integer(v.coverage.closed_shadow_roots) || !integer(v.coverage.frames_not_read)) return false;
|
|
35
|
+
for (const face of v.font_faces) {
|
|
36
|
+
if (!plain(face) || typeof face.family !== "string"
|
|
37
|
+
|| !["unloaded", "loading", "loaded", "error"].includes(face.status)) return false;
|
|
38
|
+
}
|
|
39
|
+
const indices = new Set();
|
|
40
|
+
for (const e of v.elements) {
|
|
41
|
+
if (!plain(e) || !integer(e.index) || indices.has(e.index)
|
|
42
|
+
|| !boundedText(e.selector_path, PARITY_PROBE_LIMITS.selector_path_chars) || e.selector_path === ""
|
|
43
|
+
|| !boundedText(e.text, PARITY_PROBE_LIMITS.text_chars)
|
|
44
|
+
|| !boundedText(e.ftext, PARITY_PROBE_LIMITS.text_chars)
|
|
45
|
+
|| !plain(e.box) || !coordinate(e.box.x) || !coordinate(e.box.y) || !extent(e.box.w) || !extent(e.box.h)
|
|
46
|
+
|| !["font_family", "font_style", "text_transform"].every((k) => typeof e[k] === "string")
|
|
47
|
+
|| !nonnegative(e.font_size_px) || !finite(e.font_weight) || e.font_weight < 1 || e.font_weight > 1000
|
|
48
|
+
|| !normalOr(e.line_height, nonnegative) || !normalOr(e.letter_spacing, finite)
|
|
49
|
+
|| !color(e.fg_srgb) || !color(e.bg_srgb)
|
|
50
|
+
|| !(e.color_review === null || (typeof e.color_review === "string" && e.color_review !== ""))) return false;
|
|
51
|
+
indices.add(e.index);
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validOptions(options) {
|
|
57
|
+
if (options === undefined) return true;
|
|
58
|
+
if (!plain(options) || Object.keys(options).some((k) => k !== "tolerances")) return false;
|
|
59
|
+
if (!Object.hasOwn(options, "tolerances")) return true;
|
|
60
|
+
const t = options.tolerances;
|
|
61
|
+
const keys = Object.keys(PARITY_TOLERANCES);
|
|
62
|
+
return plain(t) && Object.keys(t).length === keys.length && keys.every((k) => Object.hasOwn(t, k))
|
|
63
|
+
&& keys.filter((k) => k.endsWith("_px") || k === "color_channel").every((k) => nonnegative(t[k]))
|
|
64
|
+
&& finite(t.fuzzy_jaccard) && t.fuzzy_jaccard > 0 && t.fuzzy_jaccard <= 1
|
|
65
|
+
&& integer(t.missing_copy_min_chars, 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function norm(s) {
|
|
69
|
+
return s.normalize("NFKC").replace(/[\u2018\u2019\u201a\u201b]/gu, "'")
|
|
70
|
+
.replace(/[\u201c\u201d\u201e]/gu, '"').replace(/[\u2013\u2014\u2212]/gu, "-")
|
|
71
|
+
.replace(/\s+/gu, " ").trim().toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function key(s) {
|
|
75
|
+
return norm(s).replace(/[^\p{L}\p{M}\p{N} ]/gu, " ").replace(/ +/g, " ").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const tokens = (s) => new Set(key(s).split(" ").filter(Boolean));
|
|
79
|
+
function jaccard(a, b) {
|
|
80
|
+
if (!a.size || !b.size) return 0;
|
|
81
|
+
let common = 0;
|
|
82
|
+
for (const token of a) if (b.has(token)) common++;
|
|
83
|
+
return common / (a.size + b.size - common);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function firstFamily(s) {
|
|
87
|
+
let quote = null;
|
|
88
|
+
let entry = "";
|
|
89
|
+
for (let i = 0; i < s.length; i++) {
|
|
90
|
+
const ch = s[i];
|
|
91
|
+
if (ch === "\\") {
|
|
92
|
+
if (i + 1 < s.length) entry += s[++i];
|
|
93
|
+
} else if (ch === quote) {
|
|
94
|
+
quote = null;
|
|
95
|
+
} else if (!quote && (ch === '"' || ch === "'")) {
|
|
96
|
+
quote = ch;
|
|
97
|
+
} else if (!quote && ch === ",") {
|
|
98
|
+
const family = norm(entry);
|
|
99
|
+
if (family) return family;
|
|
100
|
+
entry = "";
|
|
101
|
+
} else {
|
|
102
|
+
entry += ch;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return norm(entry);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function median(values) {
|
|
109
|
+
if (!values.length) return 0;
|
|
110
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
111
|
+
const mid = Math.floor(sorted.length / 2);
|
|
112
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function round2(v) {
|
|
116
|
+
const rounded = Math.round(v * 100) / 100;
|
|
117
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
118
|
+
}
|
|
119
|
+
const exceeds = (delta, tolerance) => Math.abs(delta) - tolerance > 1e-9;
|
|
120
|
+
const c8 = (v) => Math.round(Math.min(1, Math.max(0, v)) * 255);
|
|
121
|
+
const roundValue = (v) => Array.isArray(v) ? v.map(roundValue) : typeof v === "number" ? round2(v) : v;
|
|
122
|
+
const designOrder = (a, b) => a.e.box.y - b.e.box.y || a.e.box.x - b.e.box.x || a.e.index - b.e.index;
|
|
123
|
+
|
|
124
|
+
function emptyComparison() {
|
|
125
|
+
return {
|
|
126
|
+
comparator_version: PARITY_COMPARATOR_VERSION, status: "compared", invalid: null,
|
|
127
|
+
drift: { dx: 0, dy: 0, samples: 0 },
|
|
128
|
+
counts: { design: 0, build: 0, matched: 0,
|
|
129
|
+
by_pass: { exact_unique: 0, exact_nearest: 0, subtree: 0, fuzzy: 0 },
|
|
130
|
+
unmatched_present: 0, unmatched_missing: 0, unmatched_unverified: 0, below_min_chars: 0 },
|
|
131
|
+
pairs: [], findings: [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Cache text before matching so the 1500 × 1500 case does no repeated
|
|
136
|
+
// normalization or font-face/page-text scanning per candidate.
|
|
137
|
+
function prepared(e) {
|
|
138
|
+
const textKey = key(e.text);
|
|
139
|
+
return { e, textKey, ftextKey: key(e.ftext), tokens: tokens(e.text !== "" ? e.text : e.ftext) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function groupedByText(elements) {
|
|
143
|
+
const groups = new Map();
|
|
144
|
+
for (const e of elements) {
|
|
145
|
+
if (!groups.has(e.textKey)) groups.set(e.textKey, []);
|
|
146
|
+
groups.get(e.textKey).push(e);
|
|
147
|
+
}
|
|
148
|
+
return groups;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function faceStatuses(faces) {
|
|
152
|
+
const map = new Map();
|
|
153
|
+
const rank = { error: 1, unloaded: 2, loading: 3, loaded: 4 };
|
|
154
|
+
for (const face of faces) {
|
|
155
|
+
const family = norm(face.family);
|
|
156
|
+
if (rank[face.status] > (rank[map.get(family)] ?? 0)) map.set(family, face.status);
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function compareElementParity(design, build, options) {
|
|
162
|
+
const out = emptyComparison();
|
|
163
|
+
const invalid = !validOptions(options) ? "options" : !isProbeOutput(design) ? "design"
|
|
164
|
+
: !isProbeOutput(build) ? "build" : null;
|
|
165
|
+
if (invalid) return { ...out, status: "invalid_input", invalid };
|
|
166
|
+
const t = options?.tolerances ?? PARITY_TOLERANCES;
|
|
167
|
+
const D = design.elements.map(prepared).filter((d) => d.textKey !== "").sort(designOrder);
|
|
168
|
+
const B = build.elements.map(prepared);
|
|
169
|
+
out.counts.design = D.length;
|
|
170
|
+
out.counts.build = B.length;
|
|
171
|
+
if (design.viewport.width !== build.viewport.width || design.viewport.height !== build.viewport.height || design.dpr !== build.dpr) {
|
|
172
|
+
out.status = "viewport_mismatch";
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
if (!D.length) {
|
|
176
|
+
out.status = "design_elements_unavailable";
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const matches = new Map();
|
|
181
|
+
const used = new Set();
|
|
182
|
+
const pair = (d, b, via, similarity = null) => {
|
|
183
|
+
matches.set(d, { b, via, similarity });
|
|
184
|
+
used.add(b);
|
|
185
|
+
out.counts.by_pass[via]++;
|
|
186
|
+
};
|
|
187
|
+
const dg = groupedByText(D);
|
|
188
|
+
const bg = groupedByText(B);
|
|
189
|
+
const xs = [], ys = [];
|
|
190
|
+
for (const d of D) {
|
|
191
|
+
const candidates = bg.get(d.textKey);
|
|
192
|
+
if (dg.get(d.textKey).length === 1 && candidates?.length === 1) {
|
|
193
|
+
const b = candidates[0];
|
|
194
|
+
pair(d, b, "exact_unique");
|
|
195
|
+
xs.push(b.e.box.x - d.e.box.x);
|
|
196
|
+
ys.push(b.e.box.y - d.e.box.y);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const dx = median(xs), dy = median(ys);
|
|
200
|
+
out.drift = { dx: round2(dx), dy: round2(dy), samples: xs.length };
|
|
201
|
+
const dist = (d, b) => Math.hypot(d.e.box.x + dx - b.e.box.x, d.e.box.y + dy - b.e.box.y);
|
|
202
|
+
const area = (b) => Math.max(1, b.e.box.w) * Math.max(1, b.e.box.h);
|
|
203
|
+
for (const via of ["exact_nearest", "subtree", "fuzzy"]) {
|
|
204
|
+
for (const d of D) {
|
|
205
|
+
if (matches.has(d)) continue;
|
|
206
|
+
let best = null, bestPrimary = Infinity, bestDistance = Infinity, similarity = null;
|
|
207
|
+
const candidates = via === "exact_nearest" ? bg.get(d.textKey) ?? [] : B;
|
|
208
|
+
for (const b of candidates) {
|
|
209
|
+
if (used.has(b)) continue;
|
|
210
|
+
let primary = 0, score = null;
|
|
211
|
+
if (via === "subtree") {
|
|
212
|
+
if (!((b.ftextKey !== "" && b.ftextKey === d.textKey)
|
|
213
|
+
|| (d.ftextKey !== "" && b.textKey !== "" && b.textKey === d.ftextKey))) continue;
|
|
214
|
+
primary = area(b);
|
|
215
|
+
} else if (via === "fuzzy") {
|
|
216
|
+
score = jaccard(d.tokens, b.tokens);
|
|
217
|
+
if (score < t.fuzzy_jaccard) continue;
|
|
218
|
+
primary = -score;
|
|
219
|
+
}
|
|
220
|
+
const distance = dist(d, b);
|
|
221
|
+
if (best === null || primary < bestPrimary || (primary === bestPrimary
|
|
222
|
+
&& (distance < bestDistance || (distance === bestDistance && b.e.index < best.e.index)))) {
|
|
223
|
+
best = b;
|
|
224
|
+
bestPrimary = primary;
|
|
225
|
+
bestDistance = distance;
|
|
226
|
+
similarity = score;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (best) pair(d, best, via, similarity === null ? null : round2(similarity));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const pageKey = ` ${key(build.page_text)} `;
|
|
234
|
+
const contains = (k) => pageKey.includes(` ${k} `);
|
|
235
|
+
const incomplete = build.coverage.page_text_truncated ? "page_text_truncated"
|
|
236
|
+
: build.coverage.closed_shadow_roots > 0 || build.coverage.frames_not_read > 0 ? "build_text_not_read" : null;
|
|
237
|
+
const designFaces = faceStatuses(design.font_faces), buildFaces = faceStatuses(build.font_faces);
|
|
238
|
+
const categories = { copy: [], typography: [], color: [], geometry: [] };
|
|
239
|
+
const ordinals = new Map();
|
|
240
|
+
let pred = null;
|
|
241
|
+
for (const d of D) {
|
|
242
|
+
const de = d.e;
|
|
243
|
+
const text = norm(de.text);
|
|
244
|
+
const identityKey = JSON.stringify([text, de.selector_path]);
|
|
245
|
+
const ordinal = ordinals.get(identityKey) ?? 0;
|
|
246
|
+
ordinals.set(identityKey, ordinal + 1);
|
|
247
|
+
const design_identity = { text, selector_path: de.selector_path, ordinal };
|
|
248
|
+
const match = matches.get(d);
|
|
249
|
+
const be = match?.b.e;
|
|
250
|
+
const members = { copy: [], typography: [], color: [], geometry: [] };
|
|
251
|
+
const add = (category, property, reason_code, dv, bv, delta = null, result = "warning") => {
|
|
252
|
+
members[category].push({ property, result, reason_code, design: roundValue(dv), build: roundValue(bv), delta: roundValue(delta) });
|
|
253
|
+
};
|
|
254
|
+
const changed = (category, property, dv, bv, tolerance, delta = bv - dv) => {
|
|
255
|
+
if (exceeds(delta, tolerance)) add(category, property, `${property}_differs`, dv, bv, delta);
|
|
256
|
+
};
|
|
257
|
+
const copy = (reason, bv, delta = null) => add("copy", "copy", reason, text, bv, delta,
|
|
258
|
+
incomplete ? "review" : "warning");
|
|
259
|
+
if (!match) {
|
|
260
|
+
if ([...d.textKey].length < t.missing_copy_min_chars) out.counts.below_min_chars++;
|
|
261
|
+
else if (contains(d.textKey)) out.counts.unmatched_present++;
|
|
262
|
+
else if (incomplete) {
|
|
263
|
+
out.counts.unmatched_unverified++;
|
|
264
|
+
copy(incomplete, null);
|
|
265
|
+
} else {
|
|
266
|
+
out.counts.unmatched_missing++;
|
|
267
|
+
copy("copy_missing", null);
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
out.pairs.push({ design: de.index, build: be.index, via: match.via, similarity: match.similarity });
|
|
271
|
+
if (match.via === "fuzzy" && !contains(d.textKey)) {
|
|
272
|
+
copy(incomplete ?? "copy_changed", norm(be.text !== "" ? be.text : be.ftext), match.similarity);
|
|
273
|
+
}
|
|
274
|
+
const df = firstFamily(de.font_family), bf = firstFamily(be.font_family);
|
|
275
|
+
if (df !== bf) add("typography", "font_family", "font_family_differs", df, bf);
|
|
276
|
+
changed("typography", "font_size", de.font_size_px, be.font_size_px, t.font_size_px);
|
|
277
|
+
changed("typography", "font_weight", de.font_weight, be.font_weight, 0);
|
|
278
|
+
if (de.font_style !== be.font_style) add("typography", "font_style", "font_style_differs", de.font_style, be.font_style);
|
|
279
|
+
if (typeof de.line_height === "number" && typeof be.line_height === "number") {
|
|
280
|
+
changed("typography", "line_height", de.line_height, be.line_height, t.line_height_px);
|
|
281
|
+
} else if (de.line_height !== be.line_height) {
|
|
282
|
+
add("typography", "line_height", "line_height_unresolved", de.line_height, be.line_height, null, "review");
|
|
283
|
+
}
|
|
284
|
+
changed("typography", "letter_spacing", de.letter_spacing, be.letter_spacing, t.letter_spacing_px,
|
|
285
|
+
(be.letter_spacing === "normal" ? 0 : be.letter_spacing) - (de.letter_spacing === "normal" ? 0 : de.letter_spacing));
|
|
286
|
+
if (de.text_transform !== be.text_transform) add("typography", "text_transform", "text_transform_differs", de.text_transform, be.text_transform);
|
|
287
|
+
if (designFaces.get(df) === "loaded" && buildFaces.get(df) !== "loaded") {
|
|
288
|
+
add("typography", "font_face", build.coverage.font_faces_capped ? "font_face_unverified" : "font_face_unavailable",
|
|
289
|
+
"loaded", buildFaces.get(df) ?? "absent", null, build.coverage.font_faces_capped ? "review" : "warning");
|
|
290
|
+
}
|
|
291
|
+
if (de.color_review !== null || be.color_review !== null || de.fg_srgb === null || be.fg_srgb === null) {
|
|
292
|
+
add("color", "color", "color_unresolved", de.color_review, be.color_review, null, "review");
|
|
293
|
+
} else {
|
|
294
|
+
for (const [property, field] of [["text_color", "fg_srgb"], ["background_color", "bg_srgb"]]) {
|
|
295
|
+
const dc = de[field]?.map(c8) ?? null, bc = be[field]?.map(c8) ?? null;
|
|
296
|
+
if (dc === null || bc === null) add("color", property, "color_unresolved", dc, bc, null, "review");
|
|
297
|
+
else changed("color", property, dc, bc, t.color_channel, Math.max(...dc.map((v, i) => Math.abs(bc[i] - v))));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const gapD = de.box.y - (pred ? pred.d.box.y + pred.d.box.h : 0);
|
|
301
|
+
const gapB = be.box.y - (pred ? pred.b.box.y + pred.b.box.h : 0);
|
|
302
|
+
changed("geometry", "gap", gapD, gapB, t.gap_px);
|
|
303
|
+
changed("geometry", "x_position", de.box.x, be.box.x, t.position_px, (be.box.x - de.box.x) - dx);
|
|
304
|
+
changed("geometry", "width", de.box.w, be.box.w, t.size_px);
|
|
305
|
+
changed("geometry", "height", de.box.h, be.box.h, t.size_px);
|
|
306
|
+
pred = { d: de, b: be };
|
|
307
|
+
}
|
|
308
|
+
for (const category of Object.keys(categories)) {
|
|
309
|
+
if (members[category].length) categories[category].push({ category, design: de.index, build: be?.index ?? null,
|
|
310
|
+
design_identity: { ...design_identity }, members: members[category] });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
out.counts.matched = out.pairs.length;
|
|
314
|
+
out.findings = Object.values(categories).flat();
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { normalizeDesignFileReference, resolveDesignFileReferences } from "../design-file-reference.mjs";
|
|
2
|
+
import { buildQcResult } from "../qc-results.mjs";
|
|
3
|
+
|
|
4
|
+
const array = (value) => Array.isArray(value) ? value : [];
|
|
5
|
+
|
|
6
|
+
function recordedType(contribution, fileRef) {
|
|
7
|
+
const type = fileRef?.design_file_type;
|
|
8
|
+
if (typeof type === "string" && type.trim()) return type;
|
|
9
|
+
return ["figma_frames", "figma_sections"].includes(contribution?.kind) ? "figma" : "other";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Recomputed from declarations and package records; a named file never proves
|
|
13
|
+
// access. In particular this evaluator has no pass or accept-eligible path.
|
|
14
|
+
export function evaluateDesignAccess({ activePages = [], mappings = [], manifest = null, designSourcePackage = null, measured_at } = {}) {
|
|
15
|
+
const contributions = Array.isArray(designSourcePackage?.contributions) ? designSourcePackage.contributions : [];
|
|
16
|
+
const surfaces = Array.isArray(designSourcePackage?.surface_identity) ? designSourcePackage.surface_identity : [];
|
|
17
|
+
const recordedPageIds = new Set();
|
|
18
|
+
const recordedByPage = new Map(activePages.map((page) => {
|
|
19
|
+
const surfaceIds = new Set(surfaces.filter((surface) => surface?.kind === "page" && surface.mappings?.campaign_spec_page_id === page.id).map((surface) => surface.id));
|
|
20
|
+
const recorded = contributions.find((contribution) => contribution?.provenance?.source_type === "design_file" && array(contribution.mappings).some((mapping) => surfaceIds.has(mapping?.surface_id)));
|
|
21
|
+
if (recorded && contributions.some((contribution) => array(contribution?.mappings).some((mapping) => surfaceIds.has(mapping?.surface_id) && mapping?.derived_from === recorded.id))) {
|
|
22
|
+
recordedPageIds.add(page.id);
|
|
23
|
+
}
|
|
24
|
+
return [page.id, recorded];
|
|
25
|
+
}));
|
|
26
|
+
const resolved = resolveDesignFileReferences({ activePages, mappings, manifest, specChannel: {
|
|
27
|
+
mode: "doctor",
|
|
28
|
+
recordedPageIds,
|
|
29
|
+
} });
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const page of activePages) {
|
|
32
|
+
const current = resolved.pages.find((entry) => entry.page_id === page.id);
|
|
33
|
+
const recorded = recordedByPage.get(page.id);
|
|
34
|
+
if (!current && !recorded) continue;
|
|
35
|
+
const fileRef = array(recorded?.source_refs).find((ref) => ref?.role === "design_file");
|
|
36
|
+
const previous = normalizeDesignFileReference({ type: recordedType(recorded, fileRef), file_url: fileRef?.url }).declaration;
|
|
37
|
+
const declaredSha = current?.declaration.file_identity_sha256 ?? null;
|
|
38
|
+
const recordedSha = previous?.file_identity_sha256 ?? null;
|
|
39
|
+
const reason_code = current?.conflict ? "design_reference_conflict"
|
|
40
|
+
: recorded && !current ? "design_reference_missing"
|
|
41
|
+
: recorded && declaredSha !== recordedSha ? "design_reference_changed"
|
|
42
|
+
: !recorded ? "design_reference_not_recorded" : "design_access_missing";
|
|
43
|
+
const result = ["design_reference_conflict", "design_reference_missing", "design_reference_changed"].includes(reason_code) ? "review" : "unexercised";
|
|
44
|
+
const state = { reason_code, recorded_identity_sha256: recordedSha, declared_identity_sha256: declaredSha, conflict_identities: [...(current?.conflict?.identities || [])].sort(), conflict_frames: [...(current?.conflict?.frames || [])].sort() };
|
|
45
|
+
const observation = {
|
|
46
|
+
type: current?.declaration.type ?? previous?.type ?? null,
|
|
47
|
+
kind: current?.conflict?.kind ?? null,
|
|
48
|
+
declared_by: current?.declared_by ?? null,
|
|
49
|
+
frames_declared: Object.keys(current?.declaration.frames || {}).sort(), reader: "none", recorded_contribution_id: recorded?.id ?? null,
|
|
50
|
+
};
|
|
51
|
+
rows.push({ ...buildQcResult({ check: "design.access", leg: "doctor", subject: { check: "design.access", page: page.id, key: "file" }, result, reason_code, state, observation, accept_eligible: false, measured_at }), state });
|
|
52
|
+
}
|
|
53
|
+
return rows;
|
|
54
|
+
}
|