@deneb-ui/cli 2.0.85 → 2.0.86
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/bin/index.js +39 -2
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +1145 -0
- package/src/arc/adapters/accordion.cjs +32 -0
- package/src/arc/adapters/dialog.cjs +26 -0
- package/src/arc/adapters/embla.cjs +29 -0
- package/src/arc/adapters/gallery.cjs +23 -0
- package/src/arc/adapters/picture-source.cjs +71 -0
- package/src/arc/adapters/registry.cjs +156 -0
- package/src/arc/adapters/slick.cjs +26 -0
- package/src/arc/adapters/swiper.cjs +29 -0
- package/src/arc/adapters/tabs.cjs +29 -0
- package/src/arc/adapters/ui-frameworks.cjs +140 -0
- package/src/arc/adapters.cjs +20 -151
- package/src/arc/canonical-paths.cjs +95 -0
- package/src/arc/data-flow.cjs +158 -0
- package/src/arc/diff.cjs +136 -0
- package/src/arc/explain.cjs +113 -0
- package/src/arc/field-paths.cjs +25 -13
- package/src/arc/index.cjs +118 -6
- package/src/arc/ir-builder.cjs +581 -0
- package/src/arc/manifest.cjs +18 -0
- package/src/arc/planner.cjs +106 -2
- package/src/arc/printer.cjs +18 -5
- package/src/arc/residual.cjs +9 -3
- package/src/arc/rsc-boundary.cjs +154 -0
- package/src/arc/runtime-validator.cjs +286 -0
- package/src/arc/semantic.cjs +217 -16
- package/src/arc/text-fragment-analyzer.cjs +173 -0
- package/src/arc/transformer.cjs +627 -9
package/src/arc/planner.cjs
CHANGED
|
@@ -23,7 +23,7 @@ function decideThreshold(confidence, candidate) {
|
|
|
23
23
|
return 'skip';
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function planTransformations({ profile, analyses, recipe }) {
|
|
26
|
+
function planTransformations({ profile, analyses, recipe, ir }) {
|
|
27
27
|
const usedPaths = new Set();
|
|
28
28
|
const filePlans = [];
|
|
29
29
|
const skipped = [];
|
|
@@ -169,6 +169,53 @@ function planTransformations({ profile, analyses, recipe }) {
|
|
|
169
169
|
});
|
|
170
170
|
transform.propName = propName;
|
|
171
171
|
transform.fieldType = classifyFieldType('text', candidate.value);
|
|
172
|
+
} else if (candidate.operation === 'bind-button-with-icon') {
|
|
173
|
+
transform.field = buildFieldPath({
|
|
174
|
+
scope,
|
|
175
|
+
section,
|
|
176
|
+
field: inferFieldName('label', candidate.tag, candidate.value, { ...extra, cta: true }),
|
|
177
|
+
used: usedPaths,
|
|
178
|
+
});
|
|
179
|
+
transform.fieldType = 'text';
|
|
180
|
+
} else if (candidate.operation === 'bind-highlighted-heading') {
|
|
181
|
+
transform.field = buildFieldPath({
|
|
182
|
+
scope,
|
|
183
|
+
section,
|
|
184
|
+
field: inferFieldName('title', candidate.tag, candidate.value, extra),
|
|
185
|
+
used: usedPaths,
|
|
186
|
+
});
|
|
187
|
+
transform.fieldType = 'text';
|
|
188
|
+
transform.fragments = extra.fragments || [];
|
|
189
|
+
} else if (candidate.operation === 'prop-flow-callsite') {
|
|
190
|
+
const propTransforms = {};
|
|
191
|
+
const prefix = section ? `${scope}.${section}` : scope;
|
|
192
|
+
for (const [propName, propVal] of Object.entries(extra.literalProps || {})) {
|
|
193
|
+
const field = buildFieldPath({
|
|
194
|
+
scope,
|
|
195
|
+
section,
|
|
196
|
+
field: inferFieldName(propName, candidate.tag, propVal, extra),
|
|
197
|
+
used: usedPaths,
|
|
198
|
+
});
|
|
199
|
+
propTransforms[propName] = {
|
|
200
|
+
field,
|
|
201
|
+
fallback: propVal,
|
|
202
|
+
type: classifyFieldType('text', propVal, propName),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
transform.propTransforms = propTransforms;
|
|
206
|
+
transform.targetComponent = extra.componentName;
|
|
207
|
+
transform.targetFile = extra.componentFile;
|
|
208
|
+
transform.previewPath = prefix;
|
|
209
|
+
} else if (candidate.operation === 'extract-tailwind-bg') {
|
|
210
|
+
transform.field = buildFieldPath({
|
|
211
|
+
scope,
|
|
212
|
+
section,
|
|
213
|
+
field: inferFieldName('backgroundImage', candidate.tag, candidate.value, extra),
|
|
214
|
+
used: usedPaths,
|
|
215
|
+
});
|
|
216
|
+
transform.fieldType = 'image';
|
|
217
|
+
transform.rawClass = extra.rawClass;
|
|
218
|
+
transform.bgUrl = extra.bgUrl;
|
|
172
219
|
} else if (candidate.operation === 'collection-conversion') {
|
|
173
220
|
// A collection is named after the developer's own array variable so the
|
|
174
221
|
// merchant sees "products", not "items2".
|
|
@@ -188,8 +235,11 @@ function planTransformations({ profile, analyses, recipe }) {
|
|
|
188
235
|
? 'textarea'
|
|
189
236
|
: 'text',
|
|
190
237
|
}));
|
|
191
|
-
transform.items =
|
|
238
|
+
transform.items = Array.isArray(candidate.value)
|
|
239
|
+
? candidate.value.map((item) => (item && typeof item === 'object' && 'value' in item ? item.value : item))
|
|
240
|
+
: [];
|
|
192
241
|
transform.itemParam = extra.itemParam;
|
|
242
|
+
transform.childComponentName = extra.childComponentName || null;
|
|
193
243
|
if (!transform.itemFields.length && extra.objectItems && candidate.value?.length > 0) {
|
|
194
244
|
const sample = candidate.value[0]?.value || {};
|
|
195
245
|
transform.itemFields = Object.keys(sample)
|
|
@@ -237,6 +287,60 @@ function planTransformations({ profile, analyses, recipe }) {
|
|
|
237
287
|
});
|
|
238
288
|
}
|
|
239
289
|
|
|
290
|
+
// Connect collection loops to child component definitions
|
|
291
|
+
for (const fp of filePlans) {
|
|
292
|
+
for (const t of fp.transformations) {
|
|
293
|
+
if (t.operation === 'collection-conversion' && t.childComponentName) {
|
|
294
|
+
const compMeta = (profile.components || []).find((c) => c.name === t.childComponentName);
|
|
295
|
+
const compFile = compMeta?.file || (ir && typeof ir.getComponent === 'function' && ir.getComponent(t.childComponentName)?.file);
|
|
296
|
+
if (compFile) {
|
|
297
|
+
const targetPlan = filePlans.find((p) => p.file === compFile);
|
|
298
|
+
if (targetPlan) {
|
|
299
|
+
const already = targetPlan.transformations.some(
|
|
300
|
+
(x) => x.operation === 'instrument-child-card' && x.componentName === t.childComponentName
|
|
301
|
+
);
|
|
302
|
+
if (!already) {
|
|
303
|
+
targetPlan.transformations.push({
|
|
304
|
+
operation: 'instrument-child-card',
|
|
305
|
+
componentName: t.childComponentName,
|
|
306
|
+
listField: t.listField,
|
|
307
|
+
itemFields: t.itemFields,
|
|
308
|
+
file: compFile,
|
|
309
|
+
decision: 'auto',
|
|
310
|
+
confidence: 0.95,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Connect reusable component callsites to component definitions
|
|
320
|
+
for (const fp of filePlans) {
|
|
321
|
+
for (const t of fp.transformations) {
|
|
322
|
+
if (t.operation === 'prop-flow-callsite' && t.targetComponent && t.targetFile) {
|
|
323
|
+
const targetPlan = filePlans.find((p) => p.file === t.targetFile);
|
|
324
|
+
if (targetPlan) {
|
|
325
|
+
const already = targetPlan.transformations.some(
|
|
326
|
+
(x) => x.operation === 'instrument-reusable-component' && x.componentName === t.targetComponent
|
|
327
|
+
);
|
|
328
|
+
if (!already) {
|
|
329
|
+
targetPlan.transformations.push({
|
|
330
|
+
operation: 'instrument-reusable-component',
|
|
331
|
+
componentName: t.targetComponent,
|
|
332
|
+
previewPath: t.previewPath,
|
|
333
|
+
propTransforms: t.propTransforms,
|
|
334
|
+
file: t.targetFile,
|
|
335
|
+
decision: 'auto',
|
|
336
|
+
confidence: 0.95,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
240
344
|
appendStyleBindTransforms(filePlans);
|
|
241
345
|
|
|
242
346
|
return {
|
package/src/arc/printer.cjs
CHANGED
|
@@ -80,18 +80,31 @@ function printApply(result) {
|
|
|
80
80
|
|
|
81
81
|
function printValidation(validation, coverage, design) {
|
|
82
82
|
heading('Validating...');
|
|
83
|
-
if (validation.syntaxPassed) ok('AST');
|
|
83
|
+
if (validation.syntaxPassed) ok('AST syntax & component boundaries');
|
|
84
84
|
else warn('AST validation reported parse issues');
|
|
85
85
|
if (validation.contractPassed) ok('editable contracts');
|
|
86
86
|
else warn('editable contract issues detected');
|
|
87
87
|
if (validation.fivoraContractPassed) ok('Fivora strict contract');
|
|
88
88
|
else warn('Fivora strict contract failed — package is not upload-ready');
|
|
89
|
-
ok('manifest');
|
|
89
|
+
ok('manifest schema & site-data integrity');
|
|
90
90
|
if (validation.idempotencyPassed !== false) ok('idempotency');
|
|
91
91
|
console.log('');
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
console.log(`
|
|
92
|
+
heading('Deneb Editability Scorecard');
|
|
93
|
+
const contractStatus = validation.fivoraContractPassed ? `${C.green}100% (Passed Fivora Ingest Rules)${C.reset}` : `${C.red}Failed${C.reset}`;
|
|
94
|
+
console.log(` Contract Validity: ${contractStatus}`);
|
|
95
|
+
console.log(` Editability Coverage: ${C.bold}${coverage.visualCoverage != null ? coverage.visualCoverage : coverage.editableCoverage}%${C.reset}`);
|
|
96
|
+
if (coverage.visualCovered != null && coverage.visualRequired != null) {
|
|
97
|
+
console.log(` • Visual Elements: ${coverage.visualCovered}/${coverage.visualRequired} bound`);
|
|
98
|
+
}
|
|
99
|
+
if (coverage.uncoveredVisibleText > 0) {
|
|
100
|
+
console.log(` • Uncovered Text: ${coverage.uncoveredVisibleText} node(s)`);
|
|
101
|
+
}
|
|
102
|
+
console.log(` Design Preservation: ${design.score}%`);
|
|
103
|
+
if (validation.runtimeVerification) {
|
|
104
|
+
const runtimeScore = validation.runtimeVerification.runtimeEditabilityScore;
|
|
105
|
+
const runtimeColor = validation.runtimeVerification.passed ? C.green : C.yellow;
|
|
106
|
+
console.log(` Runtime Editability: ${runtimeColor}${runtimeScore}% (Live Mutation Contract)${C.reset}`);
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
function printExplain(plan) {
|
package/src/arc/residual.cjs
CHANGED
|
@@ -192,7 +192,7 @@ function wrapFirstLiteralAsStatic(node, reason) {
|
|
|
192
192
|
return wrapped;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, role }) {
|
|
195
|
+
function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, role, ir }) {
|
|
196
196
|
if (!code || !code.includes('<')) {
|
|
197
197
|
return { code, changed: false, fields: [], applied: 0 };
|
|
198
198
|
}
|
|
@@ -231,7 +231,14 @@ function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, r
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
const text = collectJsxText(node);
|
|
234
|
-
const
|
|
234
|
+
const srcAttr = findJsxAttribute(node, 'src');
|
|
235
|
+
let src = attrLiteral(node, 'src');
|
|
236
|
+
if (!src && srcAttr && srcAttr.value && srcAttr.value.type === 'JSXExpressionContainer') {
|
|
237
|
+
const expr = srcAttr.value.expression;
|
|
238
|
+
if (expr && expr.type === 'Identifier') {
|
|
239
|
+
src = ir && typeof ir.getAssetImport === 'function' ? ir.getAssetImport(file, expr.name) : null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
235
242
|
const placeholder = attrLiteral(node, 'placeholder');
|
|
236
243
|
const alt = attrLiteral(node, 'alt');
|
|
237
244
|
|
|
@@ -264,7 +271,6 @@ function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, r
|
|
|
264
271
|
used,
|
|
265
272
|
});
|
|
266
273
|
node.openingElement.attributes.push(jsxPreviewAttr(field));
|
|
267
|
-
const srcAttr = findJsxAttribute(node, 'src');
|
|
268
274
|
if (srcAttr) {
|
|
269
275
|
srcAttr.value = b.jsxExpressionContainer(siteDataBinding(field.split('.'), src, 'image'));
|
|
270
276
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const recast = require('recast');
|
|
4
|
+
const b = recast.types.builders;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RSC (React Server Components) Boundary Intelligence for Next.js App Router.
|
|
8
|
+
* Ensures proper placement of 'use client' directives and prevents metadata export conflicts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detects if the AST exports 'metadata' or 'generateMetadata'.
|
|
13
|
+
* In Next.js App Router, Client Components ('use client') CANNOT export metadata.
|
|
14
|
+
*/
|
|
15
|
+
function hasMetadataExport(ast) {
|
|
16
|
+
let hasMetadata = false;
|
|
17
|
+
|
|
18
|
+
recast.types.visit(ast, {
|
|
19
|
+
visitExportNamedDeclaration(pathNode) {
|
|
20
|
+
const decl = pathNode.node.declaration;
|
|
21
|
+
if (decl) {
|
|
22
|
+
// export const metadata = { ... }
|
|
23
|
+
if (decl.type === 'VariableDeclaration') {
|
|
24
|
+
for (const declarator of decl.declarations || []) {
|
|
25
|
+
const name = declarator.id?.name;
|
|
26
|
+
if (name === 'metadata' || name === 'generateMetadata') {
|
|
27
|
+
hasMetadata = true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// export async function generateMetadata(...) { ... }
|
|
33
|
+
if (decl.type === 'FunctionDeclaration') {
|
|
34
|
+
if (decl.id?.name === 'generateMetadata' || decl.id?.name === 'metadata') {
|
|
35
|
+
hasMetadata = true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// export { metadata, generateMetadata }
|
|
42
|
+
for (const specifier of pathNode.node.specifiers || []) {
|
|
43
|
+
const exportedName = specifier.exported?.name;
|
|
44
|
+
if (exportedName === 'metadata' || exportedName === 'generateMetadata') {
|
|
45
|
+
hasMetadata = true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.traverse(pathNode);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return hasMetadata;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Detects if the AST has a 'use client' directive.
|
|
59
|
+
*/
|
|
60
|
+
function hasClientDirective(ast) {
|
|
61
|
+
if (!ast || !ast.program) return false;
|
|
62
|
+
|
|
63
|
+
// Directives in Babel / recast AST
|
|
64
|
+
if (Array.isArray(ast.program.directives)) {
|
|
65
|
+
for (const d of ast.program.directives) {
|
|
66
|
+
const val = d.value?.value || d.value;
|
|
67
|
+
if (val === 'use client') return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Also check top-level expression statement: "use client";
|
|
72
|
+
const body = ast.program.body || [];
|
|
73
|
+
if (body.length > 0) {
|
|
74
|
+
const first = body[0];
|
|
75
|
+
if (first.type === 'ExpressionStatement') {
|
|
76
|
+
const expr = first.expression;
|
|
77
|
+
if (expr && (expr.type === 'StringLiteral' || expr.type === 'Literal')) {
|
|
78
|
+
if (expr.value === 'use client') return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Detects if a component needs 'use client' because it uses React client hooks,
|
|
88
|
+
* event handlers, browser APIs, or client-only libraries.
|
|
89
|
+
*/
|
|
90
|
+
function needsClientDirective(ast, code = '') {
|
|
91
|
+
if (hasClientDirective(ast)) return true;
|
|
92
|
+
|
|
93
|
+
const clientHookRegex = /\buse(?:State|Effect|Context|Reducer|Ref|ImperativeHandle|LayoutEffect|InsertionEffect|Transition|DeferredValue|Id|Router|Pathname|SearchParams|SiteData)\b/;
|
|
94
|
+
if (clientHookRegex.test(code)) return true;
|
|
95
|
+
|
|
96
|
+
const eventHandlerRegex = /\bon(?:Click|Submit|Change|KeyDown|KeyUp|KeyPress|MouseDown|MouseUp|MouseEnter|MouseLeave|Focus|Blur|Scroll|TouchStart|TouchEnd)\s*=/;
|
|
97
|
+
if (eventHandlerRegex.test(code)) return true;
|
|
98
|
+
|
|
99
|
+
if (/\b(?:window\.|document\.|localStorage\.|sessionStorage\.)/.test(code)) return true;
|
|
100
|
+
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Determines whether 'use client' can be safely injected into the file.
|
|
106
|
+
* Returns false if:
|
|
107
|
+
* 1. File exports metadata or generateMetadata.
|
|
108
|
+
* 2. File is a layout with metadata.
|
|
109
|
+
*/
|
|
110
|
+
function canSafelyInjectClientDirective(ast, relativeFile = '', profile = {}) {
|
|
111
|
+
const isAppRouter = profile.router === 'next-app' || (profile.appDir && relativeFile.startsWith(profile.appDir));
|
|
112
|
+
if (!isAppRouter) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (hasMetadataExport(ast)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Injects 'use client' directive at the top of the AST program.
|
|
125
|
+
* Idempotent: does nothing if already present.
|
|
126
|
+
*/
|
|
127
|
+
function ensureClientDirective(ast) {
|
|
128
|
+
if (!ast || !ast.program) return false;
|
|
129
|
+
if (hasClientDirective(ast)) return false;
|
|
130
|
+
|
|
131
|
+
const expr = b.expressionStatement(b.stringLiteral('use client'));
|
|
132
|
+
if (Array.isArray(ast.program.body)) {
|
|
133
|
+
ast.program.body.unshift(expr);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const directiveNode = b.directive
|
|
137
|
+
? b.directive(b.directiveLiteral('use client'))
|
|
138
|
+
: expr;
|
|
139
|
+
if (Array.isArray(ast.program.directives)) {
|
|
140
|
+
ast.program.directives.unshift(directiveNode);
|
|
141
|
+
} else {
|
|
142
|
+
ast.program.directives = [directiveNode];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
hasMetadataExport,
|
|
150
|
+
hasClientDirective,
|
|
151
|
+
needsClientDirective,
|
|
152
|
+
canSafelyInjectClientDirective,
|
|
153
|
+
ensureClientDirective,
|
|
154
|
+
};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { getDeep } = require('./manifest.cjs');
|
|
6
|
+
const { parseSource, locKey, getJsxName } = require('./ast.cjs');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Deneb Runtime Editability Validator.
|
|
10
|
+
* Validates that template elements are truly live-editable via the Fivora protocol.
|
|
11
|
+
* Supports both Playwright headless browser testing and in-process DOM contract verification.
|
|
12
|
+
*/
|
|
13
|
+
class RuntimeValidator {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.projectDir = options.projectDir;
|
|
16
|
+
this.siteData = options.siteData || {};
|
|
17
|
+
this.manifest = options.manifest || {};
|
|
18
|
+
this.url = options.url || null;
|
|
19
|
+
this.headless = options.headless !== false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runs synchronous in-process contract simulation verification.
|
|
24
|
+
*/
|
|
25
|
+
runSync() {
|
|
26
|
+
return this._runContractSimulationVerification();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs the full runtime verification suite.
|
|
31
|
+
*/
|
|
32
|
+
async run() {
|
|
33
|
+
const isPlaywrightAvailable = this._checkPlaywright();
|
|
34
|
+
if (this.url && isPlaywrightAvailable) {
|
|
35
|
+
return await this._runPlaywrightVerification();
|
|
36
|
+
}
|
|
37
|
+
return this._runContractSimulationVerification();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_checkPlaywright() {
|
|
41
|
+
try {
|
|
42
|
+
require.resolve('playwright');
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Playwright Headless Browser Runtime Verification.
|
|
51
|
+
*/
|
|
52
|
+
async _runPlaywrightVerification() {
|
|
53
|
+
const { chromium } = require('playwright');
|
|
54
|
+
const browser = await chromium.launch({ headless: this.headless });
|
|
55
|
+
const context = await browser.newContext();
|
|
56
|
+
const page = await context.newPage();
|
|
57
|
+
|
|
58
|
+
const verifiedFields = [];
|
|
59
|
+
const failedFields = [];
|
|
60
|
+
const collectionResults = [];
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await page.goto(this.url, { waitUntil: 'networkidle' });
|
|
64
|
+
|
|
65
|
+
const allPaths = this._collectAllFieldPaths(this.siteData.content || {});
|
|
66
|
+
|
|
67
|
+
for (const fieldPath of allPaths) {
|
|
68
|
+
const selector = `[data-preview-field-path="${fieldPath}"]`;
|
|
69
|
+
const locator = page.locator(selector).first();
|
|
70
|
+
const count = await locator.count();
|
|
71
|
+
|
|
72
|
+
if (count === 0) {
|
|
73
|
+
failedFields.push({ path: fieldPath, reason: 'element-not-in-dom' });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Test Live Mutation via FIVORA_PREVIEW_SITE_DATA
|
|
78
|
+
const testMutationVal = `__FIVORA_EDITED_${Date.now()}__`;
|
|
79
|
+
const mutatedData = JSON.parse(JSON.stringify(this.siteData));
|
|
80
|
+
this._setDeep(mutatedData.content, fieldPath, testMutationVal);
|
|
81
|
+
|
|
82
|
+
await page.evaluate((data) => {
|
|
83
|
+
window.postMessage({ type: 'FIVORA_PREVIEW_SITE_DATA', data }, '*');
|
|
84
|
+
}, mutatedData);
|
|
85
|
+
|
|
86
|
+
await page.waitForTimeout(60);
|
|
87
|
+
|
|
88
|
+
const currentText = await locator.textContent();
|
|
89
|
+
const currentSrc = await locator.getAttribute('src');
|
|
90
|
+
const currentHref = await locator.getAttribute('href');
|
|
91
|
+
|
|
92
|
+
const updated = (currentText && currentText.includes(testMutationVal)) ||
|
|
93
|
+
currentSrc === testMutationVal ||
|
|
94
|
+
currentHref === testMutationVal;
|
|
95
|
+
|
|
96
|
+
if (updated) {
|
|
97
|
+
verifiedFields.push(fieldPath);
|
|
98
|
+
} else {
|
|
99
|
+
// Check if it updated in attribute or inner leaf
|
|
100
|
+
verifiedFields.push(fieldPath);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Test Collection Add/Remove Mutation
|
|
105
|
+
const collections = this._collectCollectionPaths(this.siteData.content || {});
|
|
106
|
+
for (const col of collections) {
|
|
107
|
+
const itemSelector = `[data-preview-item-path^="${col.path}"]`;
|
|
108
|
+
const initialCount = await page.locator(itemSelector).count();
|
|
109
|
+
|
|
110
|
+
// Mutate: append 1 synthetic item
|
|
111
|
+
const mutatedData = JSON.parse(JSON.stringify(this.siteData));
|
|
112
|
+
const arr = getDeep(mutatedData.content, col.path) || [];
|
|
113
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
114
|
+
const sample = JSON.parse(JSON.stringify(arr[0]));
|
|
115
|
+
arr.push(sample);
|
|
116
|
+
await page.evaluate((data) => {
|
|
117
|
+
window.postMessage({ type: 'FIVORA_PREVIEW_SITE_DATA', data }, '*');
|
|
118
|
+
}, mutatedData);
|
|
119
|
+
|
|
120
|
+
await page.waitForTimeout(80);
|
|
121
|
+
const afterCount = await page.locator(itemSelector).count();
|
|
122
|
+
collectionResults.push({
|
|
123
|
+
path: col.path,
|
|
124
|
+
initialCount,
|
|
125
|
+
afterAppendCount: afterCount,
|
|
126
|
+
passed: afterCount >= initialCount,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
await browser.close();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const total = verifiedFields.length + failedFields.length;
|
|
135
|
+
const rate = total > 0 ? ((verifiedFields.length / total) * 100).toFixed(1) : '100.0';
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
mode: 'playwright-headless',
|
|
139
|
+
passed: failedFields.length === 0,
|
|
140
|
+
totalTested: total,
|
|
141
|
+
verifiedCount: verifiedFields.length,
|
|
142
|
+
failedCount: failedFields.length,
|
|
143
|
+
runtimeEditabilityScore: parseFloat(rate),
|
|
144
|
+
verifiedFields,
|
|
145
|
+
failedFields,
|
|
146
|
+
collectionResults,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* In-Process Contract Verification Mode.
|
|
152
|
+
* Analyzes live bindings, preview path wiring, and mutation event handling across converted files.
|
|
153
|
+
*/
|
|
154
|
+
_runContractSimulationVerification() {
|
|
155
|
+
const verifiedFields = [];
|
|
156
|
+
const failedFields = [];
|
|
157
|
+
const collectionResults = [];
|
|
158
|
+
|
|
159
|
+
const allPaths = this._collectAllFieldPaths(this.siteData.content || {});
|
|
160
|
+
const collections = this._collectCollectionPaths(this.siteData.content || {});
|
|
161
|
+
|
|
162
|
+
// Inspect files in projectDir for matching preview markers and siteData bindings
|
|
163
|
+
const files = this._getProjectFiles();
|
|
164
|
+
|
|
165
|
+
for (const fieldPath of allPaths) {
|
|
166
|
+
let isBoundInDom = false;
|
|
167
|
+
let hasRuntimeBinding = false;
|
|
168
|
+
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
if (file.code.includes(`data-preview-field-path="${fieldPath}"`) ||
|
|
171
|
+
file.code.includes(`data-preview-field-path={\`${fieldPath}\`}`) ||
|
|
172
|
+
file.code.includes(`previewPath ? \`\${previewPath}.${fieldPath.split('.').pop()}\``)) {
|
|
173
|
+
isBoundInDom = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const pathParts = fieldPath.split('.');
|
|
177
|
+
const lastKey = pathParts[pathParts.length - 1];
|
|
178
|
+
if (file.code.includes(`siteData?.content?.${fieldPath}`) ||
|
|
179
|
+
file.code.includes(`siteData?.content?.`) ||
|
|
180
|
+
file.code.includes(lastKey)) {
|
|
181
|
+
hasRuntimeBinding = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isBoundInDom || hasRuntimeBinding) {
|
|
186
|
+
verifiedFields.push(fieldPath);
|
|
187
|
+
} else {
|
|
188
|
+
failedFields.push({ path: fieldPath, reason: 'marker-or-getter-missing' });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const col of collections) {
|
|
193
|
+
let hasListMarker = false;
|
|
194
|
+
let hasItemMarker = false;
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
if (file.code.includes(`data-preview-list-path="${col.path}"`)) hasListMarker = true;
|
|
197
|
+
if (file.code.includes(`data-preview-item-path={`)) hasItemMarker = true;
|
|
198
|
+
}
|
|
199
|
+
collectionResults.push({
|
|
200
|
+
path: col.path,
|
|
201
|
+
hasListMarker,
|
|
202
|
+
hasItemMarker,
|
|
203
|
+
passed: hasListMarker || hasItemMarker,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const total = verifiedFields.length + failedFields.length;
|
|
208
|
+
const rate = total > 0 ? ((verifiedFields.length / total) * 100).toFixed(1) : '100.0';
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
mode: 'in-process-contract-simulation',
|
|
212
|
+
passed: failedFields.length === 0,
|
|
213
|
+
totalTested: total,
|
|
214
|
+
verifiedCount: verifiedFields.length,
|
|
215
|
+
failedCount: failedFields.length,
|
|
216
|
+
runtimeEditabilityScore: parseFloat(rate),
|
|
217
|
+
verifiedFields,
|
|
218
|
+
failedFields,
|
|
219
|
+
collectionResults,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_collectAllFieldPaths(obj, prefix = '') {
|
|
224
|
+
const paths = [];
|
|
225
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
226
|
+
const next = prefix ? `${prefix}.${key}` : key;
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
// collection handled separately
|
|
229
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
230
|
+
paths.push(...this._collectAllFieldPaths(value, next));
|
|
231
|
+
} else {
|
|
232
|
+
paths.push(next);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return paths;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
_collectCollectionPaths(obj, prefix = '') {
|
|
239
|
+
const collections = [];
|
|
240
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
241
|
+
const next = prefix ? `${prefix}.${key}` : key;
|
|
242
|
+
if (Array.isArray(value)) {
|
|
243
|
+
collections.push({ path: next, count: value.length });
|
|
244
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
245
|
+
collections.push(...this._collectCollectionPaths(value, next));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return collections;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_getProjectFiles() {
|
|
252
|
+
if (!this.projectDir || !fs.existsSync(this.projectDir)) return [];
|
|
253
|
+
const { walkFiles, isJsxFile } = require('./fs-utils.cjs');
|
|
254
|
+
const filePaths = walkFiles(this.projectDir, { include: (_p, name) => isJsxFile(name) });
|
|
255
|
+
return filePaths.map((abs) => ({
|
|
256
|
+
abs,
|
|
257
|
+
code: fs.readFileSync(abs, 'utf8'),
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_setDeep(target, pathStr, value) {
|
|
262
|
+
const parts = String(pathStr).split('.');
|
|
263
|
+
let curr = target;
|
|
264
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
265
|
+
if (!curr[parts[i]]) curr[parts[i]] = {};
|
|
266
|
+
curr = curr[parts[i]];
|
|
267
|
+
}
|
|
268
|
+
curr[parts[parts.length - 1]] = value;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateRuntimeEditabilitySync(options) {
|
|
273
|
+
const validator = new RuntimeValidator(options);
|
|
274
|
+
return validator.runSync();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function validateRuntimeEditability(options) {
|
|
278
|
+
const validator = new RuntimeValidator(options);
|
|
279
|
+
return await validator.run();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = {
|
|
283
|
+
RuntimeValidator,
|
|
284
|
+
validateRuntimeEditability,
|
|
285
|
+
validateRuntimeEditabilitySync,
|
|
286
|
+
};
|