@deneb-ui/cli 2.0.49 → 2.0.51
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 +15 -5
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +465 -1
- package/src/arc/adapters.cjs +24 -1
- package/src/arc/ai-agent.cjs +6 -0
- package/src/arc/ai-evaluator.cjs +450 -0
- package/src/arc/ai-prompts.cjs +1 -0
- package/src/arc/ast.cjs +11 -3
- package/src/arc/field-paths.cjs +4 -0
- package/src/arc/index.cjs +30 -3
- package/src/arc/learning.cjs +31 -0
- package/src/arc/planner.cjs +18 -7
- package/src/arc/printer.cjs +20 -0
- package/src/arc/semantic.cjs +168 -37
- package/src/arc/style-candidates.cjs +2 -1
- package/src/arc/transformer.cjs +29 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deneb ARC — AI Evaluator & Self-Healing Engine
|
|
5
|
+
*
|
|
6
|
+
* Automatically audits the transformed project before finalizing `deneb init --ai`
|
|
7
|
+
* to guarantee that:
|
|
8
|
+
* 1. React Server Components (RSC) vs Client Component boundaries are strictly respected
|
|
9
|
+
* (preventing "Element type is invalid: expected string/function but got: undefined").
|
|
10
|
+
* 2. Every imported component in page.tsx/layout.tsx is properly exported.
|
|
11
|
+
* 3. Fivora strict leaf contracts are satisfied (no field-path on broad containers).
|
|
12
|
+
* 4. Manifest routes correspond 1:1 with real page files on disk.
|
|
13
|
+
* 5. Uses ChatGPT (OpenAI API) to evaluate, explain, and self-heal any edge cases.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { parseSource, printSource, getJsxName, b } = require('./ast.cjs');
|
|
19
|
+
const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
|
|
20
|
+
const { callOpenAI, checkAiReady, loadEnv } = require('./ai-agent.cjs');
|
|
21
|
+
const { recordEvaluatorFix } = require('./learning.cjs');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Audit runtime integrity of the transformed project.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} projectDir - Root path of the project being transformed
|
|
27
|
+
* @param {object} profile - Project profile from scanner.cjs
|
|
28
|
+
* @returns {Array<{ type: string, file: string, message: string, severity: 'error' | 'warning', meta?: any }>}
|
|
29
|
+
*/
|
|
30
|
+
function auditRuntimeIntegrity(projectDir, profile) {
|
|
31
|
+
const issues = [];
|
|
32
|
+
|
|
33
|
+
// 1. Check RSC Provider Boundaries in layout.tsx
|
|
34
|
+
const layoutFile = profile.appDir ? path.join(projectDir, 'src', 'app', 'layout.tsx') : null;
|
|
35
|
+
const altLayout = profile.appDir ? path.join(projectDir, 'app', 'layout.tsx') : null;
|
|
36
|
+
const targetLayout = (layoutFile && fs.existsSync(layoutFile)) ? layoutFile : ((altLayout && fs.existsSync(altLayout)) ? altLayout : null);
|
|
37
|
+
|
|
38
|
+
if (targetLayout) {
|
|
39
|
+
const layoutCode = fs.readFileSync(targetLayout, 'utf8');
|
|
40
|
+
const isServerComponent = !/['"]use client['"]/.test(layoutCode.slice(0, 300));
|
|
41
|
+
|
|
42
|
+
if (isServerComponent) {
|
|
43
|
+
// Check if client-only SiteDataProvider or createContext is directly rendered in Server Component
|
|
44
|
+
const hasSiteDataImport = /import\s+.*?\bSiteDataProvider\b.*?from\s+['"]@deneb-ui\/(?:ui|core)['"]/.test(layoutCode);
|
|
45
|
+
const rendersSiteDataProvider = /<SiteDataProvider\b/.test(layoutCode);
|
|
46
|
+
const hasProvidersWrapper = /<Providers\b/.test(layoutCode) || /from\s+['"]@\/components\/providers['"]/.test(layoutCode);
|
|
47
|
+
|
|
48
|
+
if (rendersSiteDataProvider && hasProvidersWrapper) {
|
|
49
|
+
issues.push({
|
|
50
|
+
type: 'rsc-duplicate-provider',
|
|
51
|
+
file: path.relative(projectDir, targetLayout).replace(/\\/g, '/'),
|
|
52
|
+
absPath: targetLayout,
|
|
53
|
+
message: 'Server layout renders <SiteDataProvider> inside <Providers>, causing undefined component in React Server Components.',
|
|
54
|
+
severity: 'error',
|
|
55
|
+
meta: { hasProvidersWrapper, hasSiteDataImport },
|
|
56
|
+
});
|
|
57
|
+
} else if (rendersSiteDataProvider && hasSiteDataImport) {
|
|
58
|
+
issues.push({
|
|
59
|
+
type: 'rsc-unwrapped-provider',
|
|
60
|
+
file: path.relative(projectDir, targetLayout).replace(/\\/g, '/'),
|
|
61
|
+
absPath: targetLayout,
|
|
62
|
+
message: 'Server layout renders client-only <SiteDataProvider> without a "use client" boundary.',
|
|
63
|
+
severity: 'error',
|
|
64
|
+
meta: { hasProvidersWrapper, hasSiteDataImport },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check Global CSS Import in layout.tsx
|
|
70
|
+
const hasCssImport = /import\s+['"][^'"]+\.css['"]/.test(layoutCode);
|
|
71
|
+
if (!hasCssImport) {
|
|
72
|
+
const cssCandidates = [
|
|
73
|
+
path.join(path.dirname(targetLayout), 'globals.css'),
|
|
74
|
+
path.join(path.dirname(targetLayout), 'global.css'),
|
|
75
|
+
path.join(projectDir, 'src', 'app', 'globals.css'),
|
|
76
|
+
path.join(projectDir, 'src', 'styles', 'globals.css'),
|
|
77
|
+
path.join(projectDir, 'styles', 'globals.css'),
|
|
78
|
+
];
|
|
79
|
+
const foundCss = cssCandidates.find((c) => fs.existsSync(c));
|
|
80
|
+
if (foundCss) {
|
|
81
|
+
const relPath = path.relative(path.dirname(targetLayout), foundCss).replace(/\\/g, '/');
|
|
82
|
+
const relImport = relPath.startsWith('.') ? relPath : `./${relPath}`;
|
|
83
|
+
issues.push({
|
|
84
|
+
type: 'missing-global-css-import',
|
|
85
|
+
file: path.relative(projectDir, targetLayout).replace(/\\/g, '/'),
|
|
86
|
+
absPath: targetLayout,
|
|
87
|
+
message: `Root layout is missing global stylesheet import (${path.basename(foundCss)}), which would cause unstyled HTML.`,
|
|
88
|
+
severity: 'error',
|
|
89
|
+
meta: { cssRel: relImport },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Check Component Export / Import Integrity in page.tsx
|
|
96
|
+
const pageFiles = [
|
|
97
|
+
path.join(projectDir, 'src', 'app', 'page.tsx'),
|
|
98
|
+
path.join(projectDir, 'app', 'page.tsx'),
|
|
99
|
+
path.join(projectDir, 'src', 'pages', 'index.tsx'),
|
|
100
|
+
path.join(projectDir, 'pages', 'index.tsx'),
|
|
101
|
+
];
|
|
102
|
+
const targetPage = pageFiles.find((p) => fs.existsSync(p));
|
|
103
|
+
|
|
104
|
+
if (targetPage) {
|
|
105
|
+
const pageCode = fs.readFileSync(targetPage, 'utf8');
|
|
106
|
+
const importRegex = /import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
107
|
+
let match;
|
|
108
|
+
|
|
109
|
+
while ((match = importRegex.exec(pageCode)) !== null) {
|
|
110
|
+
const clause = match[1].trim();
|
|
111
|
+
const specifier = match[2].trim();
|
|
112
|
+
|
|
113
|
+
if (!specifier.startsWith('@/') && !specifier.startsWith('.')) continue;
|
|
114
|
+
|
|
115
|
+
let resolvedFile = null;
|
|
116
|
+
if (specifier.startsWith('@/')) {
|
|
117
|
+
const candidate = path.join(projectDir, 'src', specifier.slice(2));
|
|
118
|
+
for (const ext of ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts']) {
|
|
119
|
+
if (fs.existsSync(candidate + ext) && !fs.statSync(candidate + ext).isDirectory()) {
|
|
120
|
+
resolvedFile = candidate + ext;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
const candidate = path.resolve(path.dirname(targetPage), specifier);
|
|
126
|
+
for (const ext of ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts']) {
|
|
127
|
+
if (fs.existsSync(candidate + ext) && !fs.statSync(candidate + ext).isDirectory()) {
|
|
128
|
+
resolvedFile = candidate + ext;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!resolvedFile || resolvedFile.endsWith('.json') || resolvedFile.endsWith('.css')) continue;
|
|
135
|
+
|
|
136
|
+
const targetSrc = fs.readFileSync(resolvedFile, 'utf8');
|
|
137
|
+
|
|
138
|
+
// Check named imports
|
|
139
|
+
const namedMatch = clause.match(/\{([^}]+)\}/);
|
|
140
|
+
if (namedMatch) {
|
|
141
|
+
const names = namedMatch[1].split(',').map((s) => s.trim().split(/\s+as\s+/)[0]);
|
|
142
|
+
for (const name of names) {
|
|
143
|
+
if (!name || name === 'type') continue;
|
|
144
|
+
const re = new RegExp(`\\bexport\\s+(?:const|function|class|interface|type)\\s+${name}\\b|\\bexport\\s*\\{[^}]*\\b${name}\\b`);
|
|
145
|
+
if (!re.test(targetSrc)) {
|
|
146
|
+
issues.push({
|
|
147
|
+
type: 'missing-named-export',
|
|
148
|
+
file: path.relative(projectDir, resolvedFile).replace(/\\/g, '/'),
|
|
149
|
+
absPath: resolvedFile,
|
|
150
|
+
message: `Component "${name}" imported in ${path.basename(targetPage)} is not exported from ${path.basename(resolvedFile)}.`,
|
|
151
|
+
severity: 'error',
|
|
152
|
+
meta: { componentName: name, importerFile: targetPage },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check default imports
|
|
159
|
+
const defaultMatch = clause.match(/^([A-Za-z0-9_$]+)(?:\s*,|\s*$)/);
|
|
160
|
+
if (defaultMatch && !clause.includes('{')) {
|
|
161
|
+
const defName = defaultMatch[1];
|
|
162
|
+
if (defName !== 'type' && !/export\s+default\b/.test(targetSrc)) {
|
|
163
|
+
issues.push({
|
|
164
|
+
type: 'missing-default-export',
|
|
165
|
+
file: path.relative(projectDir, resolvedFile).replace(/\\/g, '/'),
|
|
166
|
+
absPath: resolvedFile,
|
|
167
|
+
message: `File ${path.basename(resolvedFile)} lacks a default export for "${defName}".`,
|
|
168
|
+
severity: 'error',
|
|
169
|
+
meta: { componentName: defName, importerFile: targetPage },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 3. Check for broad container markers across all component files
|
|
177
|
+
for (const relFile of profile.jsxFiles || []) {
|
|
178
|
+
const absPath = path.join(projectDir, relFile);
|
|
179
|
+
if (!fs.existsSync(absPath)) continue;
|
|
180
|
+
const src = fs.readFileSync(absPath, 'utf8');
|
|
181
|
+
|
|
182
|
+
const broadRegex = /<(div|section|article|aside|header|footer|nav|main|form|ul|ol|table|thead|tbody|tr)\b([^>]*\bdata-preview-field-path\s*=[^>]*)/gi;
|
|
183
|
+
let bMatch;
|
|
184
|
+
while ((bMatch = broadRegex.exec(src)) !== null) {
|
|
185
|
+
issues.push({
|
|
186
|
+
type: 'broad-container-field-marker',
|
|
187
|
+
file: relFile.replace(/\\/g, '/'),
|
|
188
|
+
absPath,
|
|
189
|
+
message: `data-preview-field-path cannot be placed on broad <${bMatch[1]}> in ${relFile}.`,
|
|
190
|
+
severity: 'error',
|
|
191
|
+
meta: { tag: bMatch[1], snippet: bMatch[0].slice(0, 80) },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 4. Check manifest routes vs disk
|
|
197
|
+
const manifestPath = path.join(projectDir, 'fivora-template.json');
|
|
198
|
+
if (fs.existsSync(manifestPath)) {
|
|
199
|
+
try {
|
|
200
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
201
|
+
for (const p of manifest.pages || []) {
|
|
202
|
+
if (!p || !p.route) continue;
|
|
203
|
+
if (p.route === '/') continue;
|
|
204
|
+
|
|
205
|
+
const cleanRoute = p.route.replace(/^\/+/, '');
|
|
206
|
+
const exists =
|
|
207
|
+
fs.existsSync(path.join(projectDir, 'src', 'app', cleanRoute, 'page.tsx')) ||
|
|
208
|
+
fs.existsSync(path.join(projectDir, 'src', 'app', cleanRoute, 'page.jsx')) ||
|
|
209
|
+
fs.existsSync(path.join(projectDir, 'app', cleanRoute, 'page.tsx')) ||
|
|
210
|
+
fs.existsSync(path.join(projectDir, 'app', cleanRoute, 'page.jsx')) ||
|
|
211
|
+
fs.existsSync(path.join(projectDir, 'src', 'pages', `${cleanRoute}.tsx`)) ||
|
|
212
|
+
fs.existsSync(path.join(projectDir, 'src', 'pages', `${cleanRoute}.jsx`)) ||
|
|
213
|
+
fs.existsSync(path.join(projectDir, 'pages', `${cleanRoute}.tsx`)) ||
|
|
214
|
+
fs.existsSync(path.join(projectDir, 'pages', `${cleanRoute}.jsx`));
|
|
215
|
+
|
|
216
|
+
if (!exists) {
|
|
217
|
+
issues.push({
|
|
218
|
+
type: 'orphan-manifest-route',
|
|
219
|
+
file: 'fivora-template.json',
|
|
220
|
+
absPath: manifestPath,
|
|
221
|
+
message: `Manifest page "${p.id}" route "${p.route}" has no page file on disk.`,
|
|
222
|
+
severity: 'error',
|
|
223
|
+
meta: { pageId: p.id, route: p.route },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
// ignore JSON parse error
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return issues;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Heals detected runtime integrity issues automatically.
|
|
237
|
+
* Applies deterministic AST fixes for known patterns, and uses ChatGPT API for complex self-healing.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} projectDir
|
|
240
|
+
* @param {object} profile
|
|
241
|
+
* @param {Array} issues
|
|
242
|
+
* @param {object} options
|
|
243
|
+
* @returns {Promise<{ healedCount: number, remainingCount: number, log: string[] }>}
|
|
244
|
+
*/
|
|
245
|
+
async function healRuntimeIntegrity(projectDir, profile, issues, options = {}) {
|
|
246
|
+
let healedCount = 0;
|
|
247
|
+
const log = [];
|
|
248
|
+
|
|
249
|
+
for (const issue of issues) {
|
|
250
|
+
// Healing Pattern 1: RSC Duplicate or Server-rendered SiteDataProvider in layout.tsx
|
|
251
|
+
if (issue.type === 'rsc-duplicate-provider' && issue.absPath && fs.existsSync(issue.absPath)) {
|
|
252
|
+
try {
|
|
253
|
+
let code = fs.readFileSync(issue.absPath, 'utf8');
|
|
254
|
+
|
|
255
|
+
// If <Providers> exists, unwrap the redundant <SiteDataProvider> from layout.tsx
|
|
256
|
+
code = code.replace(
|
|
257
|
+
/<SiteDataProvider\s+initialSiteData=\{[^}]+\}>([\s\S]*?)<\/SiteDataProvider>/g,
|
|
258
|
+
'$1'
|
|
259
|
+
);
|
|
260
|
+
// Remove unused SiteDataProvider and initialSiteData imports
|
|
261
|
+
code = code.replace(/import\s+.*?\bSiteDataProvider\b.*?from\s+['"]@deneb-ui\/(?:ui|core)['"];?\n?/g, '');
|
|
262
|
+
code = code.replace(/import\s+initialSiteData\s+from\s+['"][^'"]+['"];?\n?/g, '');
|
|
263
|
+
|
|
264
|
+
fs.writeFileSync(issue.absPath, code, 'utf8');
|
|
265
|
+
healedCount++;
|
|
266
|
+
recordEvaluatorFix({
|
|
267
|
+
projectDir,
|
|
268
|
+
issueType: issue.type,
|
|
269
|
+
file: issue.file,
|
|
270
|
+
action: 'remove-redundant-server-site-data-provider',
|
|
271
|
+
success: true,
|
|
272
|
+
});
|
|
273
|
+
log.push(`Healed RSC boundary: removed redundant <SiteDataProvider> from server component ${issue.file}`);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
log.push(`Failed to heal ${issue.file}: ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Healing Pattern 1b: Missing global CSS stylesheet import in layout.tsx
|
|
281
|
+
if (issue.type === 'missing-global-css-import' && issue.absPath && fs.existsSync(issue.absPath)) {
|
|
282
|
+
try {
|
|
283
|
+
let code = fs.readFileSync(issue.absPath, 'utf8');
|
|
284
|
+
const cssImport = `import "${issue.meta?.cssRel || './globals.css'}";\n`;
|
|
285
|
+
const firstImportIdx = code.indexOf('import ');
|
|
286
|
+
if (firstImportIdx !== -1) {
|
|
287
|
+
const nextLineIdx = code.indexOf('\n', firstImportIdx);
|
|
288
|
+
code = code.slice(0, nextLineIdx + 1) + cssImport + code.slice(nextLineIdx + 1);
|
|
289
|
+
} else {
|
|
290
|
+
code = cssImport + code;
|
|
291
|
+
}
|
|
292
|
+
fs.writeFileSync(issue.absPath, code, 'utf8');
|
|
293
|
+
healedCount++;
|
|
294
|
+
recordEvaluatorFix({
|
|
295
|
+
projectDir,
|
|
296
|
+
issueType: issue.type,
|
|
297
|
+
file: issue.file,
|
|
298
|
+
action: 'restore-global-css-import',
|
|
299
|
+
success: true,
|
|
300
|
+
});
|
|
301
|
+
log.push(`Healed layout styles: restored ${issue.meta?.cssRel || './globals.css'} import in ${issue.file}`);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
log.push(`Failed to heal global CSS import in ${issue.file}: ${err.message}`);
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Healing Pattern 2: Broad container field path
|
|
309
|
+
if (issue.type === 'broad-container-field-marker' && issue.absPath && fs.existsSync(issue.absPath)) {
|
|
310
|
+
try {
|
|
311
|
+
let code = fs.readFileSync(issue.absPath, 'utf8');
|
|
312
|
+
// Replace <div ... data-preview-field-path="..."> with <span className="block..." ...>
|
|
313
|
+
code = code.replace(
|
|
314
|
+
/<div\b([^>]*\bdata-preview-field-path\s*=[^>]*)>([\s\S]*?)<\/div>/gi,
|
|
315
|
+
(match, attrs, inner) => {
|
|
316
|
+
let nextAttrs = attrs;
|
|
317
|
+
if (/className\s*=\s*['"]/.test(nextAttrs)) {
|
|
318
|
+
nextAttrs = nextAttrs.replace(/className\s*=\s*(['"])/, 'className=$1block ');
|
|
319
|
+
} else {
|
|
320
|
+
nextAttrs = ` className="block"${nextAttrs}`;
|
|
321
|
+
}
|
|
322
|
+
return `<span${nextAttrs}>${inner}</span>`;
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
fs.writeFileSync(issue.absPath, code, 'utf8');
|
|
326
|
+
healedCount++;
|
|
327
|
+
recordEvaluatorFix({
|
|
328
|
+
projectDir,
|
|
329
|
+
issueType: issue.type,
|
|
330
|
+
file: issue.file,
|
|
331
|
+
action: 'convert-broad-container-to-inline-span',
|
|
332
|
+
success: true,
|
|
333
|
+
});
|
|
334
|
+
log.push(`Healed broad container: converted <${issue.meta?.tag || 'div'}> to inline-block <span data-preview-field-path> in ${issue.file}`);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
log.push(`Failed to heal broad container in ${issue.file}: ${err.message}`);
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Healing Pattern 3: Orphan manifest route
|
|
342
|
+
if (issue.type === 'orphan-manifest-route' && issue.absPath && fs.existsSync(issue.absPath)) {
|
|
343
|
+
try {
|
|
344
|
+
const manifest = JSON.parse(fs.readFileSync(issue.absPath, 'utf8'));
|
|
345
|
+
if (Array.isArray(manifest.pages)) {
|
|
346
|
+
manifest.pages = manifest.pages.filter((p) => p.id !== issue.meta?.pageId && p.route !== issue.meta?.route);
|
|
347
|
+
fs.writeFileSync(issue.absPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
348
|
+
healedCount++;
|
|
349
|
+
recordEvaluatorFix({
|
|
350
|
+
projectDir,
|
|
351
|
+
issueType: issue.type,
|
|
352
|
+
file: issue.file,
|
|
353
|
+
action: 'prune-orphan-manifest-route',
|
|
354
|
+
success: true,
|
|
355
|
+
});
|
|
356
|
+
log.push(`Healed manifest: pruned non-existent route "${issue.meta?.route}" (${issue.meta?.pageId})`);
|
|
357
|
+
}
|
|
358
|
+
} catch (err) {
|
|
359
|
+
log.push(`Failed to heal manifest route: ${err.message}`);
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Healing Pattern 4: AI-Assisted Self-Healing for missing exports or complex issues
|
|
365
|
+
if (issue.type.startsWith('missing-') && options.aiEnabled && issue.absPath && fs.existsSync(issue.absPath)) {
|
|
366
|
+
loadEnv(projectDir);
|
|
367
|
+
const aiReady = checkAiReady();
|
|
368
|
+
if (aiReady.ready) {
|
|
369
|
+
try {
|
|
370
|
+
const fileSrc = fs.readFileSync(issue.absPath, 'utf8');
|
|
371
|
+
const prompt = `You are a senior React/TypeScript engineer on the DENEB UI team.
|
|
372
|
+
Fix this export error in the file:
|
|
373
|
+
ERROR: ${issue.message}
|
|
374
|
+
|
|
375
|
+
FILE SOURCE:
|
|
376
|
+
\`\`\`tsx
|
|
377
|
+
${fileSrc}
|
|
378
|
+
\`\`\`
|
|
379
|
+
|
|
380
|
+
INSTRUCTIONS:
|
|
381
|
+
1. Ensure the component "${issue.meta?.componentName}" is properly exported with a named export \`export function ${issue.meta?.componentName}()\` or proper export syntax.
|
|
382
|
+
2. Preserve all existing JSX, props, logic, and styling.
|
|
383
|
+
3. Output ONLY the raw TypeScript (.tsx) code. No markdown fences, no explanations.`;
|
|
384
|
+
|
|
385
|
+
const aiResult = await callOpenAI(prompt);
|
|
386
|
+
if (aiResult && aiResult.content) {
|
|
387
|
+
fs.writeFileSync(issue.absPath, aiResult.content, 'utf8');
|
|
388
|
+
healedCount++;
|
|
389
|
+
recordEvaluatorFix({
|
|
390
|
+
projectDir,
|
|
391
|
+
issueType: issue.type,
|
|
392
|
+
file: issue.file,
|
|
393
|
+
action: 'ai-generate-missing-export',
|
|
394
|
+
success: true,
|
|
395
|
+
});
|
|
396
|
+
log.push(`AI healed export in ${issue.file}: added export for ${issue.meta?.componentName}`);
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
log.push(`AI heal failed for ${issue.file}: ${err.message}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Re-audit after healing to calculate remaining issues
|
|
406
|
+
const remaining = auditRuntimeIntegrity(projectDir, profile);
|
|
407
|
+
return {
|
|
408
|
+
healedCount,
|
|
409
|
+
remainingCount: remaining.length,
|
|
410
|
+
remainingIssues: remaining,
|
|
411
|
+
log,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Complete evaluation pipeline called by `init --ai`.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} projectDir - Project directory
|
|
419
|
+
* @param {object} profile - Project profile
|
|
420
|
+
* @param {object} [options] - Options (aiEnabled, etc.)
|
|
421
|
+
* @returns {Promise<{ passed: boolean, issuesFound: number, healed: number, log: string[] }>}
|
|
422
|
+
*/
|
|
423
|
+
async function runAiEvaluatorPipeline(projectDir, profile, options = {}) {
|
|
424
|
+
const initialIssues = auditRuntimeIntegrity(projectDir, profile);
|
|
425
|
+
|
|
426
|
+
if (initialIssues.length === 0) {
|
|
427
|
+
return {
|
|
428
|
+
passed: true,
|
|
429
|
+
issuesFound: 0,
|
|
430
|
+
healed: 0,
|
|
431
|
+
log: ['All runtime integrity checks passed cleanly.'],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const healResult = await healRuntimeIntegrity(projectDir, profile, initialIssues, options);
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
passed: healResult.remainingCount === 0,
|
|
439
|
+
issuesFound: initialIssues.length,
|
|
440
|
+
healed: healResult.healedCount,
|
|
441
|
+
remainingIssues: healResult.remainingIssues,
|
|
442
|
+
log: healResult.log,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
module.exports = {
|
|
447
|
+
auditRuntimeIntegrity,
|
|
448
|
+
healRuntimeIntegrity,
|
|
449
|
+
runAiEvaluatorPipeline,
|
|
450
|
+
};
|
package/src/arc/ai-prompts.cjs
CHANGED
|
@@ -124,6 +124,7 @@ Your task is to create an editable wrapper component for an existing component.
|
|
|
124
124
|
10. **Use \`'use strict'\` is NOT needed** — this is a .tsx file.
|
|
125
125
|
11. **Do NOT import React hooks** like useState, useEffect, useContext. The component must be stateless.
|
|
126
126
|
12. **Do NOT import useSiteData** — the wrapper component does not need it directly.
|
|
127
|
+
13. **NEVER place data-preview-field-path on <div>, <section>, <article>, or broad containers**. ` + '`data-preview-field-path`' + ` must ONLY be on leaf text/media/control elements (<span>, <p>, <h1>-<h6>, <a>, <button>, EditableText, EditableImage).
|
|
127
128
|
|
|
128
129
|
## REFERENCE PATTERN (follow this structure exactly):
|
|
129
130
|
|
package/src/arc/ast.cjs
CHANGED
|
@@ -20,9 +20,17 @@ const BABEL_PLUGIN_SETS = [
|
|
|
20
20
|
['jsx'],
|
|
21
21
|
];
|
|
22
22
|
|
|
23
|
-
function parseWithBabel(code) {
|
|
23
|
+
function parseWithBabel(code, filePath = 'file.tsx') {
|
|
24
|
+
const isTs =
|
|
25
|
+
/\.(tsx|ts|mts|cts)$/i.test(filePath) ||
|
|
26
|
+
/(?:interface\s+[A-Za-z0-9_$]+|type\s+[A-Za-z0-9_$]+\s*=|:\s*(?:string|number|boolean|any|void|unknown|React\.)|as\s+[A-Za-z0-9_$]+)/.test(code);
|
|
27
|
+
|
|
28
|
+
const pluginSets = isTs
|
|
29
|
+
? BABEL_PLUGIN_SETS.filter((set) => set.includes('typescript'))
|
|
30
|
+
: BABEL_PLUGIN_SETS;
|
|
31
|
+
|
|
24
32
|
let lastError = null;
|
|
25
|
-
for (const plugins of
|
|
33
|
+
for (const plugins of pluginSets) {
|
|
26
34
|
try {
|
|
27
35
|
return babelParser.parse(code, {
|
|
28
36
|
sourceType: 'unambiguous',
|
|
@@ -52,7 +60,7 @@ function parseSource(code, filePath = 'file.tsx') {
|
|
|
52
60
|
...options,
|
|
53
61
|
parser: {
|
|
54
62
|
parse(source) {
|
|
55
|
-
return parseWithBabel(source);
|
|
63
|
+
return parseWithBabel(source, filePath);
|
|
56
64
|
},
|
|
57
65
|
},
|
|
58
66
|
});
|
package/src/arc/field-paths.cjs
CHANGED
|
@@ -47,6 +47,7 @@ function inferSection(context) {
|
|
|
47
47
|
['testimonials', /testimonial/],
|
|
48
48
|
['map', /map\b|location-map|google-map/],
|
|
49
49
|
['faq', /faq|accordion/],
|
|
50
|
+
['form', /form|booking|inquiry|registration/],
|
|
50
51
|
['contact', /contact|whatsapp|mailto/],
|
|
51
52
|
['featuredProducts', /featured|product-grid|collection/],
|
|
52
53
|
['newsletter', /newsletter|subscribe/],
|
|
@@ -65,6 +66,7 @@ function inferSection(context) {
|
|
|
65
66
|
|
|
66
67
|
function inferFieldName(kind, tag, text, extra = {}) {
|
|
67
68
|
if (kind === 'url') {
|
|
69
|
+
if (extra.action === 'form-submit') return 'formWhatsappUrl';
|
|
68
70
|
if (extra.platform) return `${extra.platform}Url`;
|
|
69
71
|
if (extra.action === 'whatsapp') return 'whatsappUrl';
|
|
70
72
|
if (extra.action === 'phone') return 'phoneUrl';
|
|
@@ -74,6 +76,7 @@ function inferFieldName(kind, tag, text, extra = {}) {
|
|
|
74
76
|
return fromText || (extra.action ? `${extra.action}Url` : 'ctaUrl');
|
|
75
77
|
}
|
|
76
78
|
if (kind === 'label' && extra.paired) {
|
|
79
|
+
if (extra.action === 'form-submit') return 'formSubmitLabel';
|
|
77
80
|
if (extra.action === 'whatsapp') return 'whatsappLabel';
|
|
78
81
|
if (extra.action === 'phone') return 'phoneLabel';
|
|
79
82
|
if (extra.action === 'email') return 'emailLabel';
|
|
@@ -100,6 +103,7 @@ function inferFieldName(kind, tag, text, extra = {}) {
|
|
|
100
103
|
Description: 'description',
|
|
101
104
|
Typography: 'text',
|
|
102
105
|
Badge: 'badge',
|
|
106
|
+
label: 'label',
|
|
103
107
|
button: 'label',
|
|
104
108
|
Button: 'label',
|
|
105
109
|
span: 'label',
|
package/src/arc/index.cjs
CHANGED
|
@@ -39,6 +39,7 @@ const printer = require('./printer.cjs');
|
|
|
39
39
|
const { classifyComponents } = require('./component-registry.cjs');
|
|
40
40
|
const { checkAiReady, adaptComponent, generateDocsPage, loadEnv } = require('./ai-agent.cjs');
|
|
41
41
|
const { checkGithubReady, createComponentPR } = require('./pr-agent.cjs');
|
|
42
|
+
const { runAiEvaluatorPipeline } = require('./ai-evaluator.cjs');
|
|
42
43
|
|
|
43
44
|
function parseArcOptions(raw = {}) {
|
|
44
45
|
return {
|
|
@@ -255,7 +256,7 @@ async function runDenebArcAsync(projectDir, projectName, options = {}) {
|
|
|
255
256
|
0
|
|
256
257
|
);
|
|
257
258
|
const actionCount = analyses.reduce(
|
|
258
|
-
(n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.kind === 'url').length,
|
|
259
|
+
(n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.operation === 'form-submit-action' || c.kind === 'url').length,
|
|
259
260
|
0
|
|
260
261
|
);
|
|
261
262
|
printer.printScan(profile, graph, candidateCount, actionCount);
|
|
@@ -341,7 +342,33 @@ async function runDenebArcAsync(projectDir, projectName, options = {}) {
|
|
|
341
342
|
}
|
|
342
343
|
}
|
|
343
344
|
|
|
344
|
-
|
|
345
|
+
const result = runArcTransformations(projectDir, projectName, opts, profile, graph, analyses, runId, startedAt);
|
|
346
|
+
|
|
347
|
+
if (!opts.dryRun && result && result.outcome === 'success') {
|
|
348
|
+
printer.printAiEvaluatorStart();
|
|
349
|
+
try {
|
|
350
|
+
const evalResult = await runAiEvaluatorPipeline(projectDir, profile, {
|
|
351
|
+
dryRun: opts.aiDryRun,
|
|
352
|
+
aiEnabled: opts.aiEnabled,
|
|
353
|
+
});
|
|
354
|
+
if (evalResult.healed > 0) {
|
|
355
|
+
for (const logItem of evalResult.log) {
|
|
356
|
+
printer.printAiEvaluatorHealed(logItem);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (evalResult.remainingIssues && evalResult.remainingIssues.length > 0) {
|
|
360
|
+
for (const issue of evalResult.remainingIssues) {
|
|
361
|
+
printer.printAiEvaluatorIssue(`${issue.file}: ${issue.message}`);
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
printer.printAiEvaluatorPass('All runtime integrity checks passed (RSC boundaries, exports, Fivora contracts).');
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
printer.warn(`AI Evaluator audit encountered an issue: ${err.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return result;
|
|
345
372
|
}
|
|
346
373
|
|
|
347
374
|
function runDenebArcSync(projectDir, projectName, options = {}) {
|
|
@@ -370,7 +397,7 @@ function runDenebArcSync(projectDir, projectName, options = {}) {
|
|
|
370
397
|
0
|
|
371
398
|
);
|
|
372
399
|
const actionCount = analyses.reduce(
|
|
373
|
-
(n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.kind === 'url').length,
|
|
400
|
+
(n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.operation === 'form-submit-action' || c.kind === 'url').length,
|
|
374
401
|
0
|
|
375
402
|
);
|
|
376
403
|
printer.printScan(profile, graph, candidateCount, actionCount);
|
package/src/arc/learning.cjs
CHANGED
|
@@ -118,6 +118,7 @@ function mapOperation(operation) {
|
|
|
118
118
|
case 'extract-url':
|
|
119
119
|
return 'url-extraction';
|
|
120
120
|
case 'split-action-contract':
|
|
121
|
+
case 'form-submit-action':
|
|
121
122
|
return 'contract-split';
|
|
122
123
|
case 'style-bind':
|
|
123
124
|
return 'style-bind';
|
|
@@ -199,8 +200,38 @@ function redactSecrets(value) {
|
|
|
199
200
|
.replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, '***REDACTED KEY***');
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
function recordEvaluatorFix({ projectDir, issueType, file, action, success = true }) {
|
|
204
|
+
const record = {
|
|
205
|
+
engineVersion: ARC_VERSION,
|
|
206
|
+
type: 'evaluator-fix',
|
|
207
|
+
timestamp: new Date().toISOString(),
|
|
208
|
+
issueType,
|
|
209
|
+
file,
|
|
210
|
+
action,
|
|
211
|
+
success: Boolean(success),
|
|
212
|
+
};
|
|
213
|
+
try {
|
|
214
|
+
if (projectDir) {
|
|
215
|
+
fs.mkdirSync(path.dirname(experiencePath(projectDir)), { recursive: true });
|
|
216
|
+
const local = loadJsonArray(experiencePath(projectDir));
|
|
217
|
+
writeJson(experiencePath(projectDir), [...local, record].slice(-400));
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// ignore
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
fs.mkdirSync(path.dirname(localStorePath()), { recursive: true });
|
|
224
|
+
const global = loadJsonArray(localStorePath());
|
|
225
|
+
writeJson(localStorePath(), [...global, record].slice(-800));
|
|
226
|
+
} catch {
|
|
227
|
+
// ignore
|
|
228
|
+
}
|
|
229
|
+
return record;
|
|
230
|
+
}
|
|
231
|
+
|
|
202
232
|
module.exports = {
|
|
203
233
|
recordExperience,
|
|
234
|
+
recordEvaluatorFix,
|
|
204
235
|
loadFingerprintBoost,
|
|
205
236
|
registryArchitecture,
|
|
206
237
|
redactSecrets,
|