@nextcommerce/campaigns-os 1.60.0 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +5 -5
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +768 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +28 -1
  17. package/docs/campaign-build-brief.md +16 -0
  18. package/docs/campaigns-os-build-flow.md +61 -2
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +40 -2
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +4 -4
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +6 -9
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
  46. package/skills.json +11 -11
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +2 -2
  52. package/src/cli.mjs +52 -13
  53. package/src/design-file-reference.mjs +93 -0
  54. package/src/design-source-package.mjs +55 -1
  55. package/src/doctor/checks.mjs +8 -74
  56. package/src/doctor/ci.mjs +112 -0
  57. package/src/doctor/inspect.mjs +18 -0
  58. package/src/doctor/source-provenance.mjs +74 -0
  59. package/src/evidence-debt.mjs +119 -0
  60. package/src/parity/compare.mjs +316 -0
  61. package/src/parity/design-access.mjs +54 -0
  62. package/src/parity/element-parity-record.mjs +569 -0
  63. package/src/parity/probe.mjs +205 -0
  64. package/src/parity/snapshot.mjs +110 -0
  65. package/src/png-header.mjs +87 -0
  66. package/src/polish-browser.mjs +186 -9
  67. package/src/polish-captures-record.mjs +52 -0
  68. package/src/polish-deadline.mjs +14 -0
  69. package/src/polish-gate.mjs +25 -6
  70. package/src/polish-node.mjs +81 -7
  71. package/src/polish-visual-fixtures.mjs +74 -0
  72. package/src/polish-visual.mjs +606 -0
  73. package/src/qa-analytics-correctness.mjs +14 -2
  74. package/src/qa-analytics-parity.mjs +94 -5
  75. package/src/qa-node.mjs +39 -6
  76. package/src/qc-accept.mjs +148 -6
  77. package/src/qc-check-registry.mjs +37 -1
  78. package/src/qc-results.mjs +404 -16
  79. package/src/readback.mjs +57 -13
  80. package/src/sdk-attribute-index.mjs +11 -11
  81. package/src/sdk-markup.mjs +77 -2
  82. package/src/sdk-storage-compatibility.mjs +94 -4
  83. package/src/source-html-manifest.mjs +7 -0
  84. package/src/source-html-server.mjs +129 -0
  85. package/src/stage-record.mjs +45 -7
  86. package/src/theme-gate.mjs +2 -2
  87. package/src/visual-diff.mjs +296 -0
  88. package/src/visual-reference.mjs +126 -0
@@ -1,5 +1,6 @@
1
1
  // Doctor entry points: the doctor command, packet inspection and built-output inspection.
2
2
  import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
3
+ import { assessDoctorCi, doctorCiFailure } from "./ci.mjs";
3
4
  import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
4
5
  import { withHtmlScanSnapshot } from "../html-scan.mjs";
5
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -132,6 +133,13 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
132
133
  }
133
134
 
134
135
  export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
136
+ if (args.ci === true) {
137
+ try {
138
+ JSON.parse(readFileSync(resolve(requireArg(args, "packet")), "utf8"));
139
+ } catch (error) {
140
+ return doctorCiFailure(error, { usage: true });
141
+ }
142
+ }
135
143
  // Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
136
144
  // campaign that has only a built _site/ and no full Build Packet. Resolves
137
145
  // scope from the built output and runs the built-output residue/text/
@@ -153,6 +161,16 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
153
161
  ...(qcStandIns !== undefined ? { qcStandIns } : {}),
154
162
  };
155
163
  const result = runDoctor(packetPath, doctorOptions);
164
+ if (args.ci === true) {
165
+ // Use precisely the context/report selection that inspection used,
166
+ // including explicit sidecar suppression and the context's report binding.
167
+ const workspace = resolveCampaignWorkspace(packetPath, {
168
+ contextPath: doctorOptions.contextPath,
169
+ reportPath: doctorOptions.reportPath,
170
+ followContextPointer: true,
171
+ });
172
+ result.ci = assessDoctorCi({ packet: workspace.packet, packetPath, report: readJsonIfExists(workspace.reportPath) });
173
+ }
156
174
  // Inspection and recording are separate operations. A laptop's untracked
157
175
  // built output can be stale while the delivered build's evidence is valid.
158
176
  // Only an explicit producer action may replace the retained proof artifacts;
@@ -18,6 +18,7 @@
18
18
  // validity, wrapper policy, screenshot proof) is evaluated elsewhere and is
19
19
  // untouched by this gate.
20
20
 
21
+ import { SOURCE_HASH_PATTERN } from "../source-html-manifest.mjs";
21
22
  import {
22
23
  assessCheckpointWaivers,
23
24
  checkpointStateFingerprint,
@@ -182,3 +183,76 @@ export function evaluateSourceProvenanceGates({ pages = [], blockingCodes = [],
182
183
 
183
184
  return { gates, inert: { counts, pages: inertPages } };
184
185
  }
186
+
187
+ // Returns every Figma-provenance finding in emission order, each tagged with
188
+ // its severity: "provenance" (the waivable source_html.producer_provenance*
189
+ // and source_html.files.partial/asset blockers) or "warning".
190
+ export function collectFigmaProvenanceFindings(manifest, provenance = manifest?.producer_provenance || {}, rawProvenance = manifest?.producer_provenance) {
191
+ const findings = [];
192
+ const add = (severity, code, message) => findings.push({ severity, code, message });
193
+ if (!rawProvenance) {
194
+ add(
195
+ "provenance",
196
+ "source_html.producer_provenance",
197
+ "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
198
+ );
199
+ }
200
+
201
+ if (provenance.source_type !== "semantic_figma_export") {
202
+ add(
203
+ "provenance",
204
+ "source_html.producer_provenance.source_type",
205
+ `Figma source manifest source_type is "${provenance.source_type || "missing"}"; expected "semantic_figma_export". Screenshot or hand-authored fallback output cannot satisfy the Figma provenance gate.`
206
+ );
207
+ }
208
+ if (provenance.screenshot_fallback_used !== false) {
209
+ add(
210
+ "provenance",
211
+ "source_html.producer_provenance.screenshot_fallback_used",
212
+ "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
213
+ );
214
+ }
215
+ if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
216
+ add(
217
+ "provenance",
218
+ "source_html.producer_provenance.semantic_section_count",
219
+ "Figma source manifest must report semantic_section_count > 0."
220
+ );
221
+ }
222
+ if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
223
+ add(
224
+ "provenance",
225
+ "source_html.producer_provenance.material_fingerprint",
226
+ "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
227
+ );
228
+ }
229
+
230
+ const files = Array.isArray(manifest?.files) ? manifest.files : [];
231
+ if (!files.some((entry) => entry.role === "partial")) {
232
+ add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
233
+ }
234
+ if (!files.some((entry) => entry.role === "asset")) {
235
+ add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
236
+ }
237
+
238
+ const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
239
+ if (!sectionExports.length) {
240
+ add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
241
+ } else {
242
+ const withoutNodeIds = sectionExports
243
+ .filter((entry) => {
244
+ const nodeIds = isPlainObject(entry?.node_ids) ? entry.node_ids : null;
245
+ return !nodeIds || !Object.keys(nodeIds).length;
246
+ })
247
+ .map((entry) => entry?.section || "unknown");
248
+ if (withoutNodeIds.length) {
249
+ add(
250
+ "warning",
251
+ "source_html.producer_provenance.section_exports.node_ids",
252
+ `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
253
+ );
254
+ }
255
+ }
256
+
257
+ return findings;
258
+ }
@@ -0,0 +1,119 @@
1
+ // Read-only build binding projection, shared by readback and other readers.
2
+ // No stage verdicts, clocks, Git metadata, processes, writes or network calls.
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+
5
+ import {
6
+ computeBuildFingerprint,
7
+ describeBuildOutputDrift,
8
+ pageIdForRoute,
9
+ resolveBuiltSiteScope,
10
+ routeForFile,
11
+ } from "./built-site-scope.mjs";
12
+ import { normalizePublicRouteSlug } from "./route-identity.mjs";
13
+ import { isFileReadFailure } from "./cart-placeholders.mjs";
14
+ import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
15
+
16
+ export const EVIDENCE_DEBT_STATUSES = Object.freeze(["current", "owed", "unknown"]);
17
+
18
+ const fingerprint = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
19
+ const portable = (value) => value.split(sep).join("/");
20
+
21
+ /**
22
+ * Compare recorded Polish/QA build bindings with bytes under _site/<slug>.
23
+ * `changes` is explicitly against the assembly manifest, which need not be
24
+ * the build an older stage reviewed. We cannot reconstruct an older manifest.
25
+ * Missing build inputs remain unknown; lifecycle-required stages are owed
26
+ * even without a stage binding. A matching binding alone is not a verdict.
27
+ */
28
+ export function assessEvidenceDebt({ packet = null, packetPath = null, report = null } = {}) {
29
+ const assembly = report?.stages?.assembly;
30
+ const recordedBuild = fingerprint(assembly?.build_fingerprint);
31
+ // Match polish-gate's sourceBuildFingerprint precedence without reading the
32
+ // capture package (often absent in a fresh checkout).
33
+ const polishBuild = fingerprint(report?.stages?.polish?.source_build_fingerprint)
34
+ || fingerprint(report?.stages?.polish?.evidence?.source_build_fingerprint);
35
+ const qaBuild = qaRecordedBuildFingerprint(report);
36
+ let current = null;
37
+ let scope = null;
38
+ let outputRoot = null;
39
+ let reason = null;
40
+ let scopeReason = null;
41
+ const target = fingerprint(packet?.assembly?.target_repo);
42
+ const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
43
+ if (!packet || !packetPath) reason = "packet_unavailable";
44
+ else if (!target || !slug) reason = "build_output_location_unknown";
45
+ else {
46
+ const targetRepo = resolve(dirname(resolve(packetPath)), target);
47
+ outputRoot = join(targetRepo, "_site", slug);
48
+ try {
49
+ current = computeBuildFingerprint(outputRoot);
50
+ if (!current.ok) reason = "build_output_missing";
51
+ // Resolve only the chosen built directory: never discover another
52
+ // campaign, or fall back to source HTML when _site is absent.
53
+ else {
54
+ scope = resolveBuiltSiteScope(outputRoot);
55
+ if (!scope.ok) scopeReason = "build_output_pages_unresolved";
56
+ }
57
+ } catch (error) {
58
+ if (!isFileReadFailure(error)) throw error;
59
+ reason = "build_output_unreadable";
60
+ current = null;
61
+ }
62
+ }
63
+ const currentBuild = current?.ok ? current.fingerprint : null;
64
+ const drift = currentBuild ? describeBuildOutputDrift(assembly, current) : null;
65
+ const manifestKnown = drift?.manifest === "recorded";
66
+ // A missing manifest hides page changes, never a known stage binding.
67
+ const bindingReason = reason || (!recordedBuild ? "assembly_build_fingerprint_missing" : null);
68
+ const changesReason = bindingReason || scopeReason || (!manifestKnown ? "build_manifest_unavailable" : null);
69
+ const stage = (stageRecord, recorded, matches) => {
70
+ // src/stage-record.mjs writes required_by: build; the dependency map in
71
+ // src/input-currency.mjs supplies brief/spec for input-change demotions.
72
+ // This is lifecycle debt even when retained evidence matches the output.
73
+ const required = stageRecord?.status === "required";
74
+ const requiredReason = ["build", "brief", "spec"].includes(stageRecord?.required_by)
75
+ ? `stage_required_by_${stageRecord.required_by}` : "stage_required";
76
+ return {
77
+ status: bindingReason ? "unknown" : required ? "owed" : !recorded ? "unknown" : matches ? "current" : "owed",
78
+ matches_current_build: recorded && currentBuild ? matches : null,
79
+ recorded_build_fingerprint: recorded,
80
+ current_build_fingerprint: currentBuild,
81
+ reason: bindingReason || (required ? requiredReason : !recorded ? "stage_build_fingerprint_missing" : matches ? null : "build_fingerprint_changed"),
82
+ };
83
+ };
84
+
85
+ const changes = {
86
+ status: changesReason ? "unknown" : "known",
87
+ reason: changesReason,
88
+ recorded_build_fingerprint: recordedBuild,
89
+ current_build_fingerprint: currentBuild,
90
+ changed_pages: [],
91
+ unmapped_files: [],
92
+ extra: manifestKnown ? drift.extra : [],
93
+ missing: manifestKnown ? drift.missing : [],
94
+ changed: manifestKnown ? drift.changed : [],
95
+ };
96
+ if (manifestKnown && scope?.ok) {
97
+ const pages = new Map((scope?.pages || []).map((page) => [
98
+ portable(relative(outputRoot, page.built_path)),
99
+ { page_id: page.page_id, built_path: portable(relative(outputRoot, page.built_path)) },
100
+ ]));
101
+ // Deleted HTML no longer appears in the disk map. Reuse its route/id
102
+ // projection for those manifest entries, without reading nonexistent files.
103
+ for (const path of drift.missing) {
104
+ if (!path.toLowerCase().endsWith(".html") || path.split("/").some((part) => part.startsWith(".") || part.startsWith("_") || part === "node_modules")) continue;
105
+ pages.set(path, { page_id: pageIdForRoute(routeForFile(outputRoot, join(outputRoot, path))), built_path: path });
106
+ }
107
+ const paths = [...new Set([...drift.extra, ...drift.missing, ...drift.changed])].sort();
108
+ for (const path of paths) {
109
+ const page = pages.get(path);
110
+ if (page) changes.changed_pages.push(page);
111
+ else changes.unmapped_files.push(path);
112
+ }
113
+ }
114
+ return {
115
+ polish: stage(report?.stages?.polish, polishBuild, Boolean(polishBuild && currentBuild && polishBuild === currentBuild)),
116
+ qa: stage(report?.stages?.qa, qaBuild, qaRecordedForCurrentBuild(report, currentBuild)),
117
+ changes,
118
+ };
119
+ }
@@ -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
+ }