@gakim-digital/dexter-bridge 0.13.0 → 0.13.2
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/package.json +1 -1
- package/src/agent.js +46 -26
- package/src/api.js +1 -1
- package/src/config.js +2 -2
- package/src/framerRequirementContract.js +332 -0
- package/src/framerTools.js +492 -75
- package/src/protocol.js +16 -8
- package/src/framerBehaviorContract.js +0 -229
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import crossSpawn from 'cross-spawn';
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
callPanelTool,
|
|
10
10
|
findWebflowImages,
|
|
11
11
|
getFramerProjectAuthorization,
|
|
12
12
|
webflowMcpEndpoint,
|
|
@@ -2385,6 +2385,10 @@ async function executeModelTurn(run, send, agent, options = {}) {
|
|
|
2385
2385
|
});
|
|
2386
2386
|
}
|
|
2387
2387
|
|
|
2388
|
+
// Rounds in which a model that finished with unmet checks is sent back to fix
|
|
2389
|
+
// them, within the run's time limit.
|
|
2390
|
+
const OUTCOME_REPAIR_ROUNDS = 3;
|
|
2391
|
+
|
|
2388
2392
|
async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
2389
2393
|
const product = normalizeBridgeProduct(run?.product || options.product);
|
|
2390
2394
|
const definition = definitionForRuntimeProfile(run?.runtimeProfile, agent);
|
|
@@ -2745,6 +2749,20 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
2745
2749
|
allowInsecureHttp: options.allowInsecureHttp,
|
|
2746
2750
|
})
|
|
2747
2751
|
: undefined,
|
|
2752
|
+
callPanelTool:
|
|
2753
|
+
options.apiBaseUrl && options.deviceToken
|
|
2754
|
+
? ({ callId, name, arguments: toolArguments, signal }) =>
|
|
2755
|
+
callPanelTool(options.apiBaseUrl, {
|
|
2756
|
+
deviceToken: options.deviceToken,
|
|
2757
|
+
runId: run.runId,
|
|
2758
|
+
callId,
|
|
2759
|
+
name,
|
|
2760
|
+
arguments: toolArguments,
|
|
2761
|
+
fetchImpl: options.fetchImpl,
|
|
2762
|
+
signal,
|
|
2763
|
+
allowInsecureHttp: options.allowInsecureHttp,
|
|
2764
|
+
})
|
|
2765
|
+
: undefined,
|
|
2748
2766
|
})
|
|
2749
2767
|
: webflowOutcome
|
|
2750
2768
|
? createWebflowToolRuntime({
|
|
@@ -2762,7 +2780,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
2762
2780
|
callPanelTool:
|
|
2763
2781
|
options.apiBaseUrl && options.deviceToken
|
|
2764
2782
|
? ({ callId, name, arguments: toolArguments, signal }) =>
|
|
2765
|
-
|
|
2783
|
+
callPanelTool(options.apiBaseUrl, {
|
|
2766
2784
|
deviceToken: options.deviceToken,
|
|
2767
2785
|
runId: run.runId,
|
|
2768
2786
|
callId,
|
|
@@ -3072,32 +3090,34 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
3072
3090
|
) {
|
|
3073
3091
|
synchronization = await harnessTools.synchronize();
|
|
3074
3092
|
}
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3093
|
+
// The model sees what Dexter's reads found and decides what to do with it.
|
|
3094
|
+
// Findings it has already weighed are not sent again: finishing with the
|
|
3095
|
+
// same findings is its judgment, and the outcome reports them as they are.
|
|
3096
|
+
let assessedCompletion = null;
|
|
3097
|
+
let previousRepairPrompt = null;
|
|
3098
|
+
for (let round = 0; round < OUTCOME_REPAIR_ROUNDS; round += 1) {
|
|
3099
|
+
if (typeof harnessTools.assessCompletion === 'function') {
|
|
3100
|
+
await harnessTools.assessCompletion(rawCompletion);
|
|
3101
|
+
assessedCompletion = rawCompletion;
|
|
3102
|
+
}
|
|
3103
|
+
const unappliedChangePrompt =
|
|
3104
|
+
rawCompletion.status === successStatus
|
|
3105
|
+
? harnessTools.unappliedChangePrompt?.(rawCompletion) || null
|
|
3106
|
+
: null;
|
|
3107
|
+
const repairPrompt =
|
|
3108
|
+
rawCompletion.status === successStatus
|
|
3109
|
+
? harnessTools.verificationRepairPrompt?.() || unappliedChangePrompt
|
|
3110
|
+
: harnessTools.unblockedFailurePrompt?.(rawCompletion) || null;
|
|
3111
|
+
if (
|
|
3112
|
+
!repairPrompt
|
|
3113
|
+
|| repairPrompt === previousRepairPrompt
|
|
3114
|
+
|| Date.now() - callStartedAt >= outcomeMaxDurationMs - 5_000
|
|
3115
|
+
) break;
|
|
3116
|
+
previousRepairPrompt = repairPrompt;
|
|
3090
3117
|
progress({
|
|
3091
3118
|
kind: 'status_update',
|
|
3092
3119
|
source: 'harness',
|
|
3093
|
-
message:
|
|
3094
|
-
repairPrompt === unappliedChangePrompt
|
|
3095
|
-
? `Nothing has changed in ${webflowOutcome ? 'Webflow' : 'Framer'} yet, so I’m making the change before I finish.`
|
|
3096
|
-
: rawCompletion.status === successStatus
|
|
3097
|
-
? (webflowOutcome
|
|
3098
|
-
? 'I’m completing the final Webflow verification before I finish.'
|
|
3099
|
-
: 'I’m completing the final Framer verification before I finish.')
|
|
3100
|
-
: 'Nothing is blocking this change, so I’m continuing the work.',
|
|
3120
|
+
message: 'I’m checking the result against your request before I finish.',
|
|
3101
3121
|
});
|
|
3102
3122
|
const repairResult = await Promise.race([
|
|
3103
3123
|
executeProviderOutcome(
|
|
@@ -3138,7 +3158,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
3138
3158
|
updatedAt: Date.now(),
|
|
3139
3159
|
});
|
|
3140
3160
|
}
|
|
3141
|
-
if (typeof harnessTools.assessCompletion === 'function') {
|
|
3161
|
+
if (typeof harnessTools.assessCompletion === 'function' && assessedCompletion !== rawCompletion) {
|
|
3142
3162
|
await harnessTools.assessCompletion(rawCompletion);
|
|
3143
3163
|
}
|
|
3144
3164
|
const completion =
|
package/src/api.js
CHANGED
package/src/config.js
CHANGED
|
@@ -22,7 +22,7 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
|
|
|
22
22
|
'./harnessMcpServer.js',
|
|
23
23
|
'./framerTools.js',
|
|
24
24
|
'./framerMcpClient.js',
|
|
25
|
-
'./
|
|
25
|
+
'./framerRequirementContract.js',
|
|
26
26
|
'./webflowTools.js',
|
|
27
27
|
'./taskEvidence.js',
|
|
28
28
|
'./outcomeWorkspace.js',
|
|
@@ -47,7 +47,7 @@ export const BRIDGE_CAPABILITIES = [
|
|
|
47
47
|
'framer-mcp-v1',
|
|
48
48
|
'framer-project-bootstrap-v1',
|
|
49
49
|
'framer-project-verification-v1',
|
|
50
|
-
'framer-
|
|
50
|
+
'framer-requirement-verification-v1',
|
|
51
51
|
'framer-read-website-v1',
|
|
52
52
|
'webflow-site-v2',
|
|
53
53
|
'project-shell-v1',
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// The model turns the user's request into requirements it can check: a value
|
|
2
|
+
// a layer must carry, a link, a size, a distance, a layer that must stay
|
|
3
|
+
// inside its parent. This module checks them against live Framer data, so a
|
|
4
|
+
// requirement counts as met because Framer shows it, not because the model
|
|
5
|
+
// says so. Dexter does not model Framer's features: the model names the layer
|
|
6
|
+
// and the property, and this module reads what is there.
|
|
7
|
+
|
|
8
|
+
const MAX_SEARCH_DEPTH = 6;
|
|
9
|
+
const MAX_OBSERVED_CHARS = 1000;
|
|
10
|
+
const DEFAULT_TOLERANCE_PX = 2;
|
|
11
|
+
export const MEASURES = ['width', 'height', 'gap', 'insideParent'];
|
|
12
|
+
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function text(value, maximum = 500) {
|
|
18
|
+
return typeof value === 'string'
|
|
19
|
+
? value.trim().slice(0, maximum)
|
|
20
|
+
: '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function uniqueStrings(values, maximum = 50) {
|
|
24
|
+
return Array.from(new Set(
|
|
25
|
+
(Array.isArray(values) ? values : [])
|
|
26
|
+
.map((value) => text(value, 500))
|
|
27
|
+
.filter(Boolean),
|
|
28
|
+
)).slice(0, maximum);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function contractError(code, message) {
|
|
32
|
+
return Object.assign(new Error(message), { code, statusCode: 400 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeRequirement(raw, index) {
|
|
36
|
+
const id = text(raw?.id, 120);
|
|
37
|
+
const description = text(raw?.text, 500);
|
|
38
|
+
if (!isRecord(raw) || !id || description.length < 3) {
|
|
39
|
+
throw contractError(
|
|
40
|
+
'FRAMER_REQUIREMENT_INVALID',
|
|
41
|
+
`Requirement ${index + 1} needs an id and the text of what must be true.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return { id, text: description, nodeIds: uniqueStrings(raw.nodeIds, 50) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeRequirements(rawRequirements) {
|
|
48
|
+
const requirements = (Array.isArray(rawRequirements) ? rawRequirements : [])
|
|
49
|
+
.slice(0, 40)
|
|
50
|
+
.map(normalizeRequirement);
|
|
51
|
+
const ids = new Set();
|
|
52
|
+
for (const requirement of requirements) {
|
|
53
|
+
if (ids.has(requirement.id)) {
|
|
54
|
+
throw contractError(
|
|
55
|
+
'FRAMER_REQUIREMENT_INVALID',
|
|
56
|
+
`Requirement ids must be unique; "${requirement.id}" is duplicated.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
ids.add(requirement.id);
|
|
60
|
+
}
|
|
61
|
+
return requirements;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mergeRequirements(current, next) {
|
|
65
|
+
const byId = new Map(current.map((item) => [item.id, item]));
|
|
66
|
+
next.forEach((item) => byId.set(item.id, item));
|
|
67
|
+
return [...byId.values()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function finiteNumber(value) {
|
|
71
|
+
const number = Number(value);
|
|
72
|
+
return Number.isFinite(number) ? number : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeChecks(rawChecks, requirements) {
|
|
76
|
+
const requirementIds = new Set(requirements.map((item) => item.id));
|
|
77
|
+
const checks = (Array.isArray(rawChecks) ? rawChecks : []).slice(0, 80);
|
|
78
|
+
if (!checks.length) {
|
|
79
|
+
throw contractError(
|
|
80
|
+
'FRAMER_CHECKS_REQUIRED',
|
|
81
|
+
'Send at least one check: a requirement id, a layer id, and either a property or a measure.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return checks.map((raw, index) => {
|
|
85
|
+
const requirementId = text(raw?.requirementId, 120);
|
|
86
|
+
const nodeId = text(raw?.nodeId, 200);
|
|
87
|
+
const property = text(raw?.property, 120);
|
|
88
|
+
const measure = text(raw?.measure, 40);
|
|
89
|
+
const invalid = (reason) => contractError(
|
|
90
|
+
'FRAMER_CHECK_INVALID',
|
|
91
|
+
`Check ${index + 1} ${reason}`,
|
|
92
|
+
);
|
|
93
|
+
if (!isRecord(raw) || !requirementIds.has(requirementId) || !nodeId) {
|
|
94
|
+
throw invalid(`must name a requirement from your plan (${[...requirementIds].join(', ') || 'none planned'}) and a layer id.`);
|
|
95
|
+
}
|
|
96
|
+
if (Boolean(property) === Boolean(measure)) {
|
|
97
|
+
throw invalid('needs either a property or a measure, not both.');
|
|
98
|
+
}
|
|
99
|
+
if (property) {
|
|
100
|
+
return raw.absent === true
|
|
101
|
+
? { requirementId, nodeId, property, absent: true }
|
|
102
|
+
: { requirementId, nodeId, property, contains: text(raw.contains, 500) };
|
|
103
|
+
}
|
|
104
|
+
if (!MEASURES.includes(measure)) {
|
|
105
|
+
throw invalid(`has an unknown measure; use one of ${MEASURES.join(', ')}.`);
|
|
106
|
+
}
|
|
107
|
+
const otherNodeId = text(raw.otherNodeId, 200);
|
|
108
|
+
if (measure === 'gap' && !otherNodeId) throw invalid('measures a gap, so it needs otherNodeId.');
|
|
109
|
+
const expected = finiteNumber(raw.expected);
|
|
110
|
+
if (measure !== 'insideParent' && expected === null) {
|
|
111
|
+
throw invalid(`measures ${measure}, so it needs the expected number of pixels.`);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
requirementId,
|
|
115
|
+
nodeId,
|
|
116
|
+
measure,
|
|
117
|
+
...(otherNodeId ? { otherNodeId } : {}),
|
|
118
|
+
...(expected !== null ? { expected } : {}),
|
|
119
|
+
tolerance: Math.max(0, finiteNumber(raw.tolerance) ?? DEFAULT_TOLERANCE_PX),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function checkNodeIds(checks) {
|
|
125
|
+
return uniqueStrings(checks.filter((check) => check.property).map((check) => check.nodeId), 200);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function measureChecks(checks) {
|
|
129
|
+
return checks.filter((check) => check.measure);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hasContent(value) {
|
|
133
|
+
if (value === undefined || value === null || value === '') return false;
|
|
134
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
135
|
+
if (isRecord(value)) return Object.keys(value).length > 0;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Finds the property on the node itself. Child nodes are skipped so a
|
|
140
|
+
// descendant cannot satisfy a claim about its parent. A dotted name such as
|
|
141
|
+
// `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
|
|
142
|
+
function propertyValues(node, property) {
|
|
143
|
+
const values = keyValues(node, property);
|
|
144
|
+
const [head, ...path] = property.split('.');
|
|
145
|
+
if (values.length > 0 || path.length === 0) return values;
|
|
146
|
+
return keyValues(node, head)
|
|
147
|
+
.map((value) => path.reduce(
|
|
148
|
+
(current, key) => (isRecord(current) ? current[key] : undefined),
|
|
149
|
+
value,
|
|
150
|
+
))
|
|
151
|
+
.filter(hasContent);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function keyValues(node, property) {
|
|
155
|
+
const values = [];
|
|
156
|
+
const visit = (current, depth) => {
|
|
157
|
+
if (depth > MAX_SEARCH_DEPTH || values.length >= 10) return;
|
|
158
|
+
if (Array.isArray(current)) {
|
|
159
|
+
current.forEach((item) => visit(item, depth + 1));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!isRecord(current)) return;
|
|
163
|
+
for (const [key, nested] of Object.entries(current)) {
|
|
164
|
+
if (key === 'children') continue;
|
|
165
|
+
if (key === property) {
|
|
166
|
+
if (hasContent(nested)) values.push(nested);
|
|
167
|
+
} else {
|
|
168
|
+
visit(nested, depth + 1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
visit(node, 0);
|
|
173
|
+
return values;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function serialized(value) {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.stringify(value);
|
|
179
|
+
} catch {
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function propertyVerdict(check, nodes) {
|
|
185
|
+
const node = nodes instanceof Map ? nodes.get(check.nodeId) : nodes?.[check.nodeId];
|
|
186
|
+
if (!node) return { ...check, verdict: 'unknown', reason: `layer ${check.nodeId} was not found` };
|
|
187
|
+
const values = propertyValues(node, check.property);
|
|
188
|
+
if (check.absent) {
|
|
189
|
+
return values.length === 0
|
|
190
|
+
? { ...check, verdict: 'verified', observedValue: 'not set' }
|
|
191
|
+
: {
|
|
192
|
+
...check,
|
|
193
|
+
verdict: 'contradicted',
|
|
194
|
+
reason: `${check.property} on ${check.nodeId} is set to ${serialized(values.length === 1 ? values[0] : values).slice(0, 200)}`,
|
|
195
|
+
observedValue: serialized(values.length === 1 ? values[0] : values).slice(0, MAX_OBSERVED_CHARS),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (values.length === 0) {
|
|
199
|
+
return { ...check, verdict: 'unknown', reason: `${check.property} is not set on ${check.nodeId}` };
|
|
200
|
+
}
|
|
201
|
+
const observed = serialized(values.length === 1 ? values[0] : values);
|
|
202
|
+
if (check.contains && !observed.toLowerCase().includes(check.contains.toLowerCase())) {
|
|
203
|
+
return {
|
|
204
|
+
...check,
|
|
205
|
+
verdict: 'contradicted',
|
|
206
|
+
reason: `${check.property} on ${check.nodeId} is ${observed.slice(0, 200)}, not ${check.contains}`,
|
|
207
|
+
observedValue: observed.slice(0, MAX_OBSERVED_CHARS),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { ...check, verdict: 'verified', observedValue: observed.slice(0, MAX_OBSERVED_CHARS) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// `measured` holds what the measuring program found for each measure check,
|
|
214
|
+
// in the same order: { value } in pixels, { inside, overflow } for
|
|
215
|
+
// insideParent, { unmeasured, note } when Framer could not lay the layer out
|
|
216
|
+
// for Dexter, or { error }.
|
|
217
|
+
function measureVerdict(check, measured) {
|
|
218
|
+
if (measured?.unmeasured) {
|
|
219
|
+
return { ...check, verdict: 'unmeasured', reason: measured.note || `${check.nodeId} could not be measured on this page` };
|
|
220
|
+
}
|
|
221
|
+
if (!measured || measured.error) {
|
|
222
|
+
return { ...check, verdict: 'unknown', reason: measured?.error || `${check.nodeId} could not be measured` };
|
|
223
|
+
}
|
|
224
|
+
if (check.measure === 'insideParent') {
|
|
225
|
+
return measured.inside
|
|
226
|
+
? { ...check, verdict: 'verified', observedValue: 'inside its parent' }
|
|
227
|
+
: {
|
|
228
|
+
...check,
|
|
229
|
+
verdict: 'contradicted',
|
|
230
|
+
reason: `${check.nodeId} sticks out of its parent by ${measured.overflow}px`,
|
|
231
|
+
observedValue: `overflows by ${measured.overflow}px`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const value = Number(measured.value);
|
|
235
|
+
const label = check.measure === 'gap' ? `gap between ${check.nodeId} and ${check.otherNodeId}` : `${check.measure} of ${check.nodeId}`;
|
|
236
|
+
if (Math.abs(value - check.expected) <= check.tolerance) {
|
|
237
|
+
return { ...check, verdict: 'verified', observedValue: `${value}px` };
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
...check,
|
|
241
|
+
verdict: 'contradicted',
|
|
242
|
+
reason: `${label} measures ${value}px, not ${check.expected}px`,
|
|
243
|
+
observedValue: `${value}px`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function verifyChecks({ checks, nodes, measurements = [] }) {
|
|
248
|
+
let measureIndex = 0;
|
|
249
|
+
return checks.map((check) => (check.property
|
|
250
|
+
? propertyVerdict(check, nodes)
|
|
251
|
+
: measureVerdict(check, measurements[measureIndex++])));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Summarizes the latest check results for every requirement. A requirement is
|
|
255
|
+
// met once every layer it names has a verified check (or, naming none, once
|
|
256
|
+
// one check verified it) and no check contradicts it. A measure Dexter could
|
|
257
|
+
// not take is neither met nor failed: the requirement is unmeasured, and the
|
|
258
|
+
// model judges it another way, such as from a screenshot.
|
|
259
|
+
// status: missing (a requirement or one of its layers was never checked),
|
|
260
|
+
// contradicted (Framer shows something else), unverified (a property or
|
|
261
|
+
// layer could not be found), or verified.
|
|
262
|
+
export function summarizeRequirements(requirements, checkResults) {
|
|
263
|
+
const items = requirements.map((requirement) => {
|
|
264
|
+
const results = checkResults.filter((result) => result.requirementId === requirement.id);
|
|
265
|
+
const verifiedNodeIds = new Set(
|
|
266
|
+
results.filter((result) => result.verdict === 'verified').map((result) => result.nodeId),
|
|
267
|
+
);
|
|
268
|
+
const contradictions = results
|
|
269
|
+
.filter((result) => result.verdict === 'contradicted')
|
|
270
|
+
.map((result) => result.reason);
|
|
271
|
+
const checkedNodeIds = new Set(results.map((result) => result.nodeId));
|
|
272
|
+
const uncheckedNodeIds = requirement.nodeIds.filter((nodeId) => !checkedNodeIds.has(nodeId));
|
|
273
|
+
const covered = requirement.nodeIds.length > 0
|
|
274
|
+
? requirement.nodeIds.every((nodeId) => verifiedNodeIds.has(nodeId))
|
|
275
|
+
: verifiedNodeIds.size > 0;
|
|
276
|
+
const unmeasuredOnly = results.length > 0
|
|
277
|
+
&& results.every((result) => result.verdict === 'verified' || result.verdict === 'unmeasured');
|
|
278
|
+
return {
|
|
279
|
+
requirementId: requirement.id,
|
|
280
|
+
text: requirement.text,
|
|
281
|
+
checked: results.length > 0 && uncheckedNodeIds.length === 0,
|
|
282
|
+
verdict: contradictions.length > 0
|
|
283
|
+
? 'contradicted'
|
|
284
|
+
: covered ? 'verified' : unmeasuredOnly ? 'unmeasured' : 'unknown',
|
|
285
|
+
uncheckedNodeIds,
|
|
286
|
+
contradictions: uniqueStrings(contradictions, 20),
|
|
287
|
+
unknowns: uniqueStrings(
|
|
288
|
+
results.filter((result) => result.verdict === 'unknown').map((result) => result.reason),
|
|
289
|
+
20,
|
|
290
|
+
),
|
|
291
|
+
unmeasured: uniqueStrings(
|
|
292
|
+
results.filter((result) => result.verdict === 'unmeasured').map((result) => result.reason),
|
|
293
|
+
20,
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
const status = items.some((item) => !item.checked)
|
|
298
|
+
? 'missing'
|
|
299
|
+
: items.some((item) => item.verdict === 'contradicted')
|
|
300
|
+
? 'contradicted'
|
|
301
|
+
: items.some((item) => item.verdict === 'unknown')
|
|
302
|
+
? 'unverified'
|
|
303
|
+
: 'verified';
|
|
304
|
+
return { status, requirements: items };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Compares two snapshots of layers that had to stay as they were. A snapshot
|
|
308
|
+
// maps a layer id to { name, rect, props } where props is the serialized
|
|
309
|
+
// layer without children and rect is null when Dexter could not measure the
|
|
310
|
+
// layer, so only its settings are compared. Boxes are compared only when both
|
|
311
|
+
// readings came from the same source (the user's editor or Framer MCP).
|
|
312
|
+
export function unchangedViolations(before, after, tolerance = 1) {
|
|
313
|
+
const changes = [];
|
|
314
|
+
for (const [id, earlier] of Object.entries(before || {})) {
|
|
315
|
+
const later = after?.[id];
|
|
316
|
+
const name = earlier.name || id;
|
|
317
|
+
if (!later) {
|
|
318
|
+
changes.push(`${name} was removed`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const a = earlier.rect;
|
|
322
|
+
const b = later.rect;
|
|
323
|
+
const comparable = a && b && (a.source || null) === (b.source || null);
|
|
324
|
+
if (comparable && ['x', 'y', 'width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
|
|
325
|
+
changes.push(`${name} moved or resized (${a.width}×${a.height} at ${a.x},${a.y} → ${b.width}×${b.height} at ${b.x},${b.y})`);
|
|
326
|
+
} else if (earlier.props !== later.props) {
|
|
327
|
+
changes.push(`${name} has different settings`);
|
|
328
|
+
}
|
|
329
|
+
if (changes.length >= 20) break;
|
|
330
|
+
}
|
|
331
|
+
return changes;
|
|
332
|
+
}
|