@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.
- package/CHANGELOG.md +126 -0
- package/README.md +5 -5
- 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 +768 -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 +28 -1
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaigns-os-build-flow.md +61 -2
- 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 +40 -2
- 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 +4 -4
- 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 +6 -9
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
- package/skills.json +11 -11
- 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 +2 -2
- package/src/cli.mjs +52 -13
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +8 -74
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +18 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -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 +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- 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/stage-record.mjs +45 -7
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/doctor/inspect.mjs
CHANGED
|
@@ -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
|
+
}
|