@nextcommerce/campaigns-os 1.59.0 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +242 -0
- package/README.md +11 -10
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +1311 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +42 -13
- package/docs/campaign-build-brief.md +17 -1
- package/docs/campaigns-os-build-flow.md +62 -3
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +41 -3
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -5
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +12 -16
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
- package/skills.json +18 -10
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +7 -14
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +89 -73
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +14 -77
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +20 -8
- package/src/doctor/next-step.mjs +6 -3
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +26 -11
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/progress-node.mjs +2 -1
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +63 -9
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
|
@@ -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
|
+
}
|
|
@@ -50,9 +50,12 @@ export function reportKeyForCliStage(cliStage) {
|
|
|
50
50
|
* "completed", "completed_with_warnings", and "completed_partial" all
|
|
51
51
|
* count as terminal under "completed". This matches how the existing
|
|
52
52
|
* stages already report sub-statuses (see report.stages.assembly.status
|
|
53
|
-
* shapes in src/cli.mjs and qa/shared/qa-verdict.js).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* shapes in src/cli.mjs and qa/shared/qa-verdict.js). This is the rule for a
|
|
54
|
+
* RECORDED status only (the session progress summary's incomplete stages,
|
|
55
|
+
* the prepare-build gate, doctor's checks). Whether a stage is done is
|
|
56
|
+
* decided on its effective status (src/input-currency.mjs), which the `next`
|
|
57
|
+
* picker and the Assembly Report's derived summary (src/stage-ledger.mjs)
|
|
58
|
+
* read: a stage owed again is not done, whatever status it records.
|
|
56
59
|
*/
|
|
57
60
|
export const STAGE_TERMINAL_STATUS_PREFIXES = Object.freeze(["completed", "skipped"]);
|
|
58
61
|
|
|
@@ -165,9 +165,29 @@ export function evaluatePageKitBuildSummary({
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// page-kit >= 0.3.0 neither builds nor copies .html under assets/ and lists
|
|
169
|
+
// each such file in a top-level assetWarnings array (ASSET_IGNORED), never
|
|
170
|
+
// in pages[].warnings. A link or iframe to one 404s on the built site, and
|
|
171
|
+
// this is the only signal, so it is surfaced, but never escalated: removing
|
|
172
|
+
// or moving the file is the author's call. Entries carry no campaignSlug;
|
|
173
|
+
// the file path (src/<slug>/assets/...) names the campaign.
|
|
174
|
+
const assetWarnings = Array.isArray(summary.assetWarnings)
|
|
175
|
+
? summary.assetWarnings.filter((warning) => typeof warning?.file === "string" && `/${warning.file.replace(/\\/g, "/")}`.includes(`/${publicRouteSlug}/assets/`))
|
|
176
|
+
: [];
|
|
177
|
+
for (const warning of assetWarnings) {
|
|
178
|
+
flagged += 1;
|
|
179
|
+
warnings.push({
|
|
180
|
+
code: "built_output.build_asset_warning",
|
|
181
|
+
message:
|
|
182
|
+
`Page Kit did not build or copy "${warning.file}": [${warning.code}] ${warning.message}. ` +
|
|
183
|
+
`A link or iframe to it 404s on the built site; move it out of assets/ if it is a page, or remove it.`,
|
|
184
|
+
detail: { file: warning.file, warning_code: warning.code },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
if (failed === 0 && flagged === 0) {
|
|
169
189
|
ready.push(
|
|
170
|
-
`Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors or
|
|
190
|
+
`Page Kit build summary verified for "${publicRouteSlug}": ${campaignPages.length} page(s) built, no build errors, shape warnings or asset warnings`
|
|
171
191
|
);
|
|
172
192
|
}
|
|
173
193
|
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { compareVersions, extractVersion } from "./repo-scan.mjs";
|
|
3
4
|
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
4
5
|
|
|
5
6
|
export const PAGE_KIT_CAMPAIGNS_REL_PATH = "_data/campaigns.json";
|
|
6
7
|
|
|
8
|
+
// next-campaign-page-kit 0.3.0 stopped building or copying .html under a
|
|
9
|
+
// campaign's top-level assets/. Source-page discovery follows the target's
|
|
10
|
+
// installed page-kit, read from node_modules the way tooling setup reads it;
|
|
11
|
+
// a missing, unreadable or malformed install keeps the 0.2.x rule. That is
|
|
12
|
+
// deliberate: doctor's built-page lookup calls this from about ten checks, so
|
|
13
|
+
// throwing would take doctor down over a broken install that the build itself
|
|
14
|
+
// fails on loudly, and tooling setup already refuses a declared page-kit that
|
|
15
|
+
// is not installed.
|
|
16
|
+
export const PAGE_KIT_ASSETS_HTML_IGNORED_FROM = "0.3.0";
|
|
17
|
+
export function pageKitIgnoresAssetsHtml(targetRepo) {
|
|
18
|
+
try {
|
|
19
|
+
const { version } = JSON.parse(readFileSync(join(targetRepo, "node_modules", "next-campaign-page-kit", "package.json"), "utf8"));
|
|
20
|
+
return extractVersion(version) !== null && compareVersions(version, PAGE_KIT_ASSETS_HTML_IGNORED_FROM) >= 0;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
// Public doctor/next/verdict evidence must never inherit arbitrary merchant
|
|
8
27
|
// configuration from campaigns.json. The raw entry is validation-local; this
|
|
9
28
|
// fixed projection is the only loader metadata safe to retain in artifacts.
|
|
@@ -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
|
+
}
|