@deneb-ui/cli 2.0.49 → 2.0.50

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 CHANGED
@@ -233,8 +233,15 @@ function detectPages(projectDir) {
233
233
  }
234
234
  }
235
235
 
236
- const hasContact = pages.some((p) => p.id === 'contact' || p.route === '/contact');
237
- if (!hasContact) {
236
+ const contactExistsOnDisk = candidateDirs.some((cDir) =>
237
+ fs.existsSync(path.join(cDir, 'contact.tsx')) ||
238
+ fs.existsSync(path.join(cDir, 'contact.jsx')) ||
239
+ fs.existsSync(path.join(cDir, 'contact.js')) ||
240
+ fs.existsSync(path.join(cDir, 'contact', 'page.tsx')) ||
241
+ fs.existsSync(path.join(cDir, 'contact', 'page.jsx')) ||
242
+ fs.existsSync(path.join(cDir, 'contact', 'page.js'))
243
+ );
244
+ if (contactExistsOnDisk && !pages.some((p) => p.id === 'contact' || p.route === '/contact')) {
238
245
  pages.push({ id: 'contact', label: 'Contact', route: '/contact', required: true });
239
246
  }
240
247
 
@@ -597,7 +604,7 @@ function getComponentRegistry(importPkg) {
597
604
  };
598
605
  }
599
606
 
600
- function initProject(targetInput, options = {}) {
607
+ async function initProject(targetInput, options = {}) {
601
608
  const targetDir = path.resolve(process.cwd(), targetInput || '.');
602
609
  const pkgPath = path.join(targetDir, 'package.json');
603
610
 
@@ -645,7 +652,7 @@ function initProject(targetInput, options = {}) {
645
652
  conversionRes = runUniversalTemplateConversion(targetDir, projectName, detectedPages, options);
646
653
  } else {
647
654
  const { runDenebArc } = require('../src/arc/index.cjs');
648
- conversionRes = runDenebArc(targetDir, projectName, {
655
+ conversionRes = await runDenebArc(targetDir, projectName, {
649
656
  ...options,
650
657
  detectedPages,
651
658
  });
@@ -1048,7 +1055,10 @@ if (command === 'init') {
1048
1055
  targetInput = arg;
1049
1056
  }
1050
1057
  }
1051
- initProject(targetInput, { recipeName, dryRun, explain, legacy, telemetry, aiEnabled, aiDryRun });
1058
+ initProject(targetInput, { recipeName, dryRun, explain, legacy, telemetry, aiEnabled, aiDryRun }).catch((err) => {
1059
+ console.error(`\x1b[31mError:\x1b[0m ${err.message}`);
1060
+ process.exit(1);
1061
+ });
1052
1062
  } else if (command === 'create') {
1053
1063
  createTemplate(commandArgs[0]);
1054
1064
  } else if (command === 'add') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.49",
3
+ "version": "2.0.50",
4
4
  "description": "Official DENEB CLI — scaffold, convert, validate, and package Fivora-ready storefront templates.",
5
5
  "bin": {
6
6
  "deneb": "bin/index.js",
@@ -49,7 +49,7 @@
49
49
  "license": "MIT",
50
50
  "dependencies": {
51
51
  "@babel/parser": "^7.28.0",
52
- "@deneb-ui/core": "^2.0.49",
52
+ "@deneb-ui/core": "^2.0.50",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -16,6 +16,8 @@ const { runDenebArc } = require('../index.cjs');
16
16
  const { parseSource } = require('../ast.cjs');
17
17
  const { loadFingerprintBoost } = require('../learning.cjs');
18
18
  const { classifyActionIntent } = require('../adapters.cjs');
19
+ const { validateGeneratedCode } = require('../ai-agent.cjs');
20
+ const { auditRuntimeIntegrity, healRuntimeIntegrity, runAiEvaluatorPipeline } = require('../ai-evaluator.cjs');
19
21
 
20
22
  test('field-paths recognizes list action CTA keys', () => {
21
23
  assert.equal(isListActionCtaKey('preOrderCta'), true);
@@ -875,3 +877,341 @@ export function ActionPage() {
875
877
  assert.deepEqual(errors, []);
876
878
  });
877
879
 
880
+ test('parseSource parses TypeScript interface declarations without experimental syntax errors', () => {
881
+ const tsCode = `
882
+ import React from 'react';
883
+ import { EditableText } from './EditableText';
884
+
885
+ export interface EditableTradeinSectionProps {
886
+ itemPath: string;
887
+ title: string;
888
+ }
889
+
890
+ export function EditableTradeinSection({ itemPath, title }: EditableTradeinSectionProps) {
891
+ return (
892
+ <div data-preview-item-path={itemPath}>
893
+ <EditableText as="h2" id={\`\${itemPath}.title\`} data-preview-field-path={\`\${itemPath}.title\`} defaultValue={title} />
894
+ </div>
895
+ );
896
+ }
897
+ `;
898
+ assert.doesNotThrow(() => {
899
+ parseSource(tsCode, 'EditableTradeinSection.tsx');
900
+ });
901
+ });
902
+
903
+ test('validateGeneratedCode catches data-preview-field-path placed on broad <div> containers', () => {
904
+ const invalidCode = `
905
+ import React from 'react';
906
+ import { EditableText } from './EditableText';
907
+
908
+ export interface EditableCardProps {
909
+ itemPath: string;
910
+ }
911
+
912
+ export function EditableCard({ itemPath }: EditableCardProps) {
913
+ return (
914
+ <div data-preview-item-path={itemPath}>
915
+ <div data-preview-field-path={\`\${itemPath}.content\`}>Some broad content</div>
916
+ </div>
917
+ );
918
+ }
919
+ `;
920
+ const res = validateGeneratedCode(invalidCode, 'EditableCard.tsx');
921
+ assert.equal(res.passed, false);
922
+ assert.ok(res.errors.some((e) => e.includes('cannot be placed on broad <div> content containers')));
923
+ });
924
+
925
+ test('AST transformer wraps literal text inside <div> with <span> instead of placing field path on <div>', () => {
926
+ const code = `
927
+ export function Card() {
928
+ return (
929
+ <div className="card">
930
+ <div className="title-row">In-Store VIP Lab</div>
931
+ </div>
932
+ );
933
+ }
934
+ `;
935
+ const profile = {
936
+ root: os.tmpdir(),
937
+ framework: 'nextjs',
938
+ router: 'next-app',
939
+ language: 'typescript',
940
+ cssSystems: ['tailwind'],
941
+ hasSrc: true,
942
+ aliasMap: { '@/*': ['src/*'] },
943
+ };
944
+
945
+ const analysis = analyzeFile({
946
+ code,
947
+ relativeFile: 'src/components/Card.tsx',
948
+ profile,
949
+ graph: { sharedFiles: [] },
950
+ ownerScope: 'home',
951
+ componentMeta: { name: 'Card', role: 'card' },
952
+ });
953
+ analysis.code = code;
954
+ analysis.relativeFile = 'src/components/Card.tsx';
955
+
956
+ const plan = planTransformations({ profile, analyses: [analysis] });
957
+ const res = applyFilePlan(plan.files[0], profile);
958
+ assert.equal(res.changed, true);
959
+ // The outer <div> must NOT have data-preview-field-path
960
+ assert.doesNotMatch(res.code, /<div[^>]*data-preview-field-path/);
961
+ // The inner text must be wrapped in a <span> with data-preview-field-path
962
+ assert.match(res.code, /<span[^>]*data-preview-field-path=/);
963
+ });
964
+
965
+ test('AI Evaluator audits and heals RSC duplicate SiteDataProvider in layout.tsx', async () => {
966
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-'));
967
+ const appDir = path.join(tmp, 'src', 'app');
968
+ fs.mkdirSync(appDir, { recursive: true });
969
+
970
+ const brokenLayout = `
971
+ import { SiteDataProvider } from '@deneb-ui/ui';
972
+ import { Providers } from '@/components/providers';
973
+ import initialSiteData from '@/data/site-data.json';
974
+
975
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
976
+ return (
977
+ <html lang="en">
978
+ <body>
979
+ <Providers>
980
+ <SiteDataProvider initialSiteData={initialSiteData}>
981
+ {children}
982
+ </SiteDataProvider>
983
+ </Providers>
984
+ </body>
985
+ </html>
986
+ );
987
+ }
988
+ `;
989
+ fs.writeFileSync(path.join(appDir, 'layout.tsx'), brokenLayout, 'utf8');
990
+
991
+ const profile = {
992
+ root: tmp,
993
+ framework: 'nextjs',
994
+ router: 'next-app',
995
+ appDir: 'src/app',
996
+ language: 'typescript',
997
+ jsxFiles: ['src/app/layout.tsx'],
998
+ };
999
+
1000
+ const issues = auditRuntimeIntegrity(tmp, profile);
1001
+ assert.equal(issues.length, 1);
1002
+ assert.equal(issues[0].type, 'rsc-duplicate-provider');
1003
+
1004
+ const healRes = await healRuntimeIntegrity(tmp, profile, issues);
1005
+ assert.equal(healRes.healedCount, 1);
1006
+ assert.equal(healRes.remainingCount, 0);
1007
+
1008
+ const fixed = fs.readFileSync(path.join(appDir, 'layout.tsx'), 'utf8');
1009
+ assert.doesNotMatch(fixed, /<SiteDataProvider/);
1010
+ assert.match(fixed, /<Providers>\s*\{children\}\s*<\/Providers>/);
1011
+
1012
+ fs.rmSync(tmp, { recursive: true, force: true });
1013
+ });
1014
+
1015
+ test('AI Evaluator detects missing component exports in page.tsx imports', () => {
1016
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-exp-'));
1017
+ const appDir = path.join(tmp, 'src', 'app');
1018
+ const compDir = path.join(tmp, 'src', 'components');
1019
+ fs.mkdirSync(appDir, { recursive: true });
1020
+ fs.mkdirSync(compDir, { recursive: true });
1021
+
1022
+ fs.writeFileSync(
1023
+ path.join(appDir, 'page.tsx'),
1024
+ `import { ProductList, MissingHero } from '@/components/Widgets';\nexport default function Page() { return <div><ProductList /></div>; }`,
1025
+ 'utf8'
1026
+ );
1027
+
1028
+ fs.writeFileSync(
1029
+ path.join(compDir, 'Widgets.tsx'),
1030
+ `export function ProductList() { return <div>Products</div>; }`,
1031
+ 'utf8'
1032
+ );
1033
+
1034
+ const profile = {
1035
+ root: tmp,
1036
+ framework: 'nextjs',
1037
+ router: 'next-app',
1038
+ appDir: 'src/app',
1039
+ language: 'typescript',
1040
+ jsxFiles: ['src/app/page.tsx', 'src/components/Widgets.tsx'],
1041
+ };
1042
+
1043
+ const issues = auditRuntimeIntegrity(tmp, profile);
1044
+ assert.equal(issues.length, 1);
1045
+ assert.equal(issues[0].type, 'missing-named-export');
1046
+ assert.equal(issues[0].meta?.componentName, 'MissingHero');
1047
+
1048
+ fs.rmSync(tmp, { recursive: true, force: true });
1049
+ });
1050
+
1051
+ test('AI Evaluator runAiEvaluatorPipeline passes on clean valid project', async () => {
1052
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-clean-'));
1053
+ const appDir = path.join(tmp, 'src', 'app');
1054
+ fs.mkdirSync(appDir, { recursive: true });
1055
+
1056
+ fs.writeFileSync(
1057
+ path.join(appDir, 'layout.tsx'),
1058
+ `import { Providers } from '@/components/providers';\nexport default function RootLayout({ children }: { children: React.ReactNode }) { return <html><body><Providers>{children}</Providers></body></html>; }`,
1059
+ 'utf8'
1060
+ );
1061
+
1062
+ const profile = {
1063
+ root: tmp,
1064
+ framework: 'nextjs',
1065
+ router: 'next-app',
1066
+ appDir: 'src/app',
1067
+ language: 'typescript',
1068
+ jsxFiles: ['src/app/layout.tsx'],
1069
+ };
1070
+
1071
+ const res = await runAiEvaluatorPipeline(tmp, profile);
1072
+ assert.equal(res.passed, true);
1073
+ assert.equal(res.issuesFound, 0);
1074
+
1075
+ fs.rmSync(tmp, { recursive: true, force: true });
1076
+ });
1077
+
1078
+ test('AI Evaluator audits and heals missing global CSS stylesheet import in layout.tsx', async () => {
1079
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-eval-css-'));
1080
+ const appDir = path.join(tmp, 'src', 'app');
1081
+ fs.mkdirSync(appDir, { recursive: true });
1082
+ fs.writeFileSync(path.join(appDir, 'globals.css'), '@import "tailwindcss";', 'utf8');
1083
+
1084
+ const unstyledLayout = `
1085
+ import { Providers } from '@/components/providers';
1086
+
1087
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1088
+ return <html><body><Providers>{children}</Providers></body></html>;
1089
+ }
1090
+ `;
1091
+ fs.writeFileSync(path.join(appDir, 'layout.tsx'), unstyledLayout, 'utf8');
1092
+
1093
+ const profile = {
1094
+ root: tmp,
1095
+ framework: 'nextjs',
1096
+ router: 'next-app',
1097
+ appDir: 'src/app',
1098
+ language: 'typescript',
1099
+ jsxFiles: ['src/app/layout.tsx'],
1100
+ };
1101
+
1102
+ const issues = auditRuntimeIntegrity(tmp, profile);
1103
+ assert.equal(issues.length, 1);
1104
+ assert.equal(issues[0].type, 'missing-global-css-import');
1105
+
1106
+ const healRes = await healRuntimeIntegrity(tmp, profile, issues);
1107
+ assert.equal(healRes.healedCount, 1);
1108
+
1109
+ const fixed = fs.readFileSync(path.join(appDir, 'layout.tsx'), 'utf8');
1110
+ assert.match(fixed, /import\s+['"]\.\/globals\.css['"]/);
1111
+
1112
+ fs.rmSync(tmp, { recursive: true, force: true });
1113
+ });
1114
+
1115
+ test('collections with nested arrays, objects, and TS as const convert to editable list contracts', () => {
1116
+ const code = `
1117
+ const phones = [
1118
+ {
1119
+ name: 'iPhone 16 Pro Max',
1120
+ brand: 'Apple',
1121
+ subtitle: 'Grade 5 Titanium' as const,
1122
+ price: 1199,
1123
+ storageOptions: ['256GB', '512GB', '1TB'],
1124
+ colors: [{ name: 'Desert Titanium', hex: '#bba795' }],
1125
+ },
1126
+ {
1127
+ name: 'Galaxy S25 Ultra',
1128
+ brand: 'Samsung',
1129
+ subtitle: 'Armor Titanium' as const,
1130
+ price: 1299,
1131
+ storageOptions: ['256GB', '512GB'],
1132
+ colors: [{ name: 'Titanium Gray', hex: '#5e6166' }],
1133
+ },
1134
+ ];
1135
+
1136
+ export function FeaturedPhones() {
1137
+ return (
1138
+ <div className="phones-grid">
1139
+ {phones.map((phone, idx) => (
1140
+ <div key={phone.name} className="phone-card">
1141
+ <h4>{phone.brand}</h4>
1142
+ <h3>{phone.name}</h3>
1143
+ <p>{phone.subtitle}</p>
1144
+ <span>Rs {phone.price}</span>
1145
+ </div>
1146
+ ))}
1147
+ </div>
1148
+ );
1149
+ }
1150
+ `;
1151
+
1152
+ const profile = { framework: 'nextjs', router: 'next-app', appDir: 'src/app', language: 'typescript' };
1153
+ const analysis = analyzeFile({
1154
+ code,
1155
+ relativeFile: 'src/components/FeaturedPhones.tsx',
1156
+ profile,
1157
+ graph: { sharedFiles: [] },
1158
+ ownerScope: 'home',
1159
+ componentMeta: { name: 'FeaturedPhones', role: 'shop' },
1160
+ });
1161
+
1162
+ const collectionCandidate = analysis.candidates.find((c) => c.kind === 'collection');
1163
+ assert.ok(collectionCandidate, 'collection with nested arrays and TS as const must be detected');
1164
+ assert.equal(collectionCandidate.extra.objectItems, true);
1165
+ assert.ok(collectionCandidate.confidence >= 0.8, 'collection must have high confidence');
1166
+
1167
+ const plan = planTransformations({
1168
+ profile,
1169
+ analyses: [analysis],
1170
+ });
1171
+
1172
+ assert.equal(plan.files.length, 1);
1173
+ const collectionTransform = plan.files[0].transformations.find((t) => t.fieldType === 'list');
1174
+ assert.ok(collectionTransform, 'collection must be planned as list field');
1175
+ assert.equal(collectionTransform.listField, 'home.phones');
1176
+ assert.ok(collectionTransform.itemFields.some((f) => f.key === 'name'));
1177
+ assert.ok(collectionTransform.itemFields.some((f) => f.key === 'price'));
1178
+ });
1179
+
1180
+ test('proceed to order button is recognized as WhatsApp order split-action contract', () => {
1181
+ const code = `
1182
+ export function CartDrawer() {
1183
+ return (
1184
+ <div className="cart-footer">
1185
+ <button type="button" className="btn-primary w-full py-4 font-bold">
1186
+ Proceed to Order · RS 1299
1187
+ </button>
1188
+ </div>
1189
+ );
1190
+ }
1191
+ `;
1192
+
1193
+ const profile = { framework: 'nextjs', router: 'next-app', appDir: 'src/app', language: 'typescript' };
1194
+ const analysis = analyzeFile({
1195
+ code,
1196
+ relativeFile: 'src/components/CartDrawer.tsx',
1197
+ profile,
1198
+ graph: { sharedFiles: [] },
1199
+ ownerScope: 'home',
1200
+ componentMeta: { name: 'CartDrawer', role: 'cart' },
1201
+ });
1202
+
1203
+ const actionCandidate = analysis.candidates.find((c) => c.kind === 'split-action-contract');
1204
+ assert.ok(actionCandidate, 'proceed to order must be recognized as split-action-contract');
1205
+ assert.equal(actionCandidate.extra.action, 'whatsapp');
1206
+
1207
+ const plan = planTransformations({
1208
+ profile,
1209
+ analyses: [analysis],
1210
+ });
1211
+
1212
+ const filePlan = plan.files[0];
1213
+ const splitAction = filePlan.transformations.find((t) => t.operation === 'split-action-contract');
1214
+ assert.ok(splitAction, 'split action must be planned');
1215
+ assert.match(splitAction.urlField, /whatsapp/i);
1216
+ });
1217
+
@@ -193,7 +193,11 @@ function classifyActionIntent(text, href) {
193
193
  const h = String(href || '').trim().toLowerCase();
194
194
 
195
195
  // 1. WhatsApp
196
- if (/wa\.me|whatsapp/i.test(h) || /\b(?:whatsapp|wa\.me)\b/i.test(t) || /order on whatsapp|chat on whatsapp|message on whatsapp/i.test(t)) {
196
+ if (
197
+ /wa\.me|whatsapp/i.test(h) ||
198
+ /\b(?:whatsapp|wa\.me)\b/i.test(t) ||
199
+ /order on whatsapp|chat on whatsapp|message on whatsapp|whatsapp order|order via whatsapp|proceed to order|proceed to checkout|complete order|confirm priority repair|book repair|schedule repair|confirm repair/i.test(t)
200
+ ) {
197
201
  return {
198
202
  action: 'whatsapp',
199
203
  defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://wa.me/1234567890',
@@ -167,6 +167,12 @@ function validateGeneratedCode(code, filename) {
167
167
  errors.push('Missing data-preview-field-path markers — the component has no editable fields.');
168
168
  }
169
169
 
170
+ // 2b. data-preview-field-path cannot be placed on broad containers
171
+ const broadMatch = code.match(/<(div|section|article|aside|header|footer|nav|main|form|ul|ol|table|thead|tbody|tr)\b[^>]*\bdata-preview-field-path\s*=/i);
172
+ if (broadMatch) {
173
+ errors.push(`data-preview-field-path cannot be placed on broad <${broadMatch[1]}> content containers. Put the marker on the exact visible text, media, link, or control element (like <span>, <p>, <h1>, <a>, <button>, or <EditableText>).`);
174
+ }
175
+
170
176
  // 3. Must have data-preview-item-path on the root wrapper
171
177
  if (!code.includes('data-preview-item-path')) {
172
178
  errors.push('Missing data-preview-item-path on root wrapper element.');
@@ -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
+ };
@@ -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 BABEL_PLUGIN_SETS) {
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/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 {
@@ -341,7 +342,33 @@ async function runDenebArcAsync(projectDir, projectName, options = {}) {
341
342
  }
342
343
  }
343
344
 
344
- return runArcTransformations(projectDir, projectName, opts, profile, graph, analyses, runId, startedAt);
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 = {}) {
@@ -199,8 +199,38 @@ function redactSecrets(value) {
199
199
  .replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, '***REDACTED KEY***');
200
200
  }
201
201
 
202
+ function recordEvaluatorFix({ projectDir, issueType, file, action, success = true }) {
203
+ const record = {
204
+ engineVersion: ARC_VERSION,
205
+ type: 'evaluator-fix',
206
+ timestamp: new Date().toISOString(),
207
+ issueType,
208
+ file,
209
+ action,
210
+ success: Boolean(success),
211
+ };
212
+ try {
213
+ if (projectDir) {
214
+ fs.mkdirSync(path.dirname(experiencePath(projectDir)), { recursive: true });
215
+ const local = loadJsonArray(experiencePath(projectDir));
216
+ writeJson(experiencePath(projectDir), [...local, record].slice(-400));
217
+ }
218
+ } catch {
219
+ // ignore
220
+ }
221
+ try {
222
+ fs.mkdirSync(path.dirname(localStorePath()), { recursive: true });
223
+ const global = loadJsonArray(localStorePath());
224
+ writeJson(localStorePath(), [...global, record].slice(-800));
225
+ } catch {
226
+ // ignore
227
+ }
228
+ return record;
229
+ }
230
+
202
231
  module.exports = {
203
232
  recordExperience,
233
+ recordEvaluatorFix,
204
234
  loadFingerprintBoost,
205
235
  registryArchitecture,
206
236
  redactSecrets,
@@ -177,8 +177,16 @@ function planTransformations({ profile, analyses, recipe }) {
177
177
  }));
178
178
  transform.items = candidate.value.map((item) => item.value);
179
179
  transform.itemParam = extra.itemParam;
180
- transform.indexParam = extra.indexParam;
181
- if (!transform.itemFields.length || !extra.objectItems) transform.decision = 'skip';
180
+ if (!transform.itemFields.length && extra.objectItems && candidate.value?.length > 0) {
181
+ const sample = candidate.value[0]?.value || {};
182
+ transform.itemFields = Object.keys(sample)
183
+ .filter((k) => typeof sample[k] === 'string' || typeof sample[k] === 'number')
184
+ .map((k) => ({
185
+ key: k,
186
+ type: typeof sample[k] === 'number' ? 'number' : /image|photo|avatar/i.test(k) ? 'image' : /url|link/i.test(k) ? 'url' : 'text',
187
+ }));
188
+ }
189
+ if (!transform.itemFields.length || !extra.objectItems || extra.hasComponentRef) transform.decision = 'skip';
182
190
  } else {
183
191
  transform.field = buildFieldPath({
184
192
  scope,
@@ -202,6 +202,22 @@ function printAiSummary(adapted, skipped, totalTokens) {
202
202
  console.log(`\n ${C.cyan}⚡${C.reset} AI Agent Summary: ${C.green}${adapted} adapted${C.reset}, ${C.yellow}${skipped} skipped${C.reset}, ~${totalTokens.toLocaleString()} tokens used`);
203
203
  }
204
204
 
205
+ function printAiEvaluatorStart() {
206
+ console.log(`\n ${C.cyan}⚡${C.reset} ${C.bold}AI Evaluator:${C.reset} Running pre-flight runtime integrity audit...`);
207
+ }
208
+
209
+ function printAiEvaluatorPass(message) {
210
+ console.log(` ${C.green}✓${C.reset} ${message}`);
211
+ }
212
+
213
+ function printAiEvaluatorHealed(message) {
214
+ console.log(` ${C.cyan}🔧${C.reset} ${C.bold}Auto-healed:${C.reset} ${message}`);
215
+ }
216
+
217
+ function printAiEvaluatorIssue(message) {
218
+ console.log(` ${C.yellow}⚠${C.reset} ${message}`);
219
+ }
220
+
205
221
  module.exports = {
206
222
  printBanner,
207
223
  printProfile,
@@ -223,6 +239,10 @@ module.exports = {
223
239
  printAiSkipped,
224
240
  printAiPr,
225
241
  printAiSummary,
242
+ printAiEvaluatorStart,
243
+ printAiEvaluatorPass,
244
+ printAiEvaluatorHealed,
245
+ printAiEvaluatorIssue,
226
246
  ok,
227
247
  warn,
228
248
  info,
@@ -49,35 +49,53 @@ function isStaticSkipText(text) {
49
49
  return false;
50
50
  }
51
51
 
52
+ function unwrapTypeCasts(node) {
53
+ let curr = node;
54
+ while (
55
+ curr &&
56
+ (curr.type === 'TSAsExpression' ||
57
+ curr.type === 'TSTypeAssertion' ||
58
+ curr.type === 'TypeCastExpression' ||
59
+ curr.type === 'TSNonNullExpression')
60
+ ) {
61
+ curr = curr.expression;
62
+ }
63
+ return curr;
64
+ }
65
+
52
66
  function collectStringBindings(ast) {
53
67
  const bindings = new Map();
54
68
  recast.types.visit(ast, {
55
69
  visitVariableDeclarator(pathNode) {
56
70
  const node = pathNode.node;
57
71
  if (node.id && node.id.type === 'Identifier' && node.init) {
58
- if (node.init.type === 'StringLiteral' || node.init.type === 'Literal' && typeof node.init.value === 'string') {
59
- bindings.set(node.id.name, String(node.init.value));
72
+ const init = unwrapTypeCasts(node.init);
73
+ if (init.type === 'StringLiteral' || (init.type === 'Literal' && typeof init.value === 'string')) {
74
+ bindings.set(node.id.name, String(init.value));
60
75
  }
61
- if (node.init.type === 'ArrayExpression') {
76
+ if (init.type === 'ArrayExpression') {
62
77
  const items = [];
63
- let allLiteral = true;
64
- for (const el of node.init.elements || []) {
78
+ for (const el of init.elements || []) {
65
79
  if (!el) continue;
66
- if (el.type === 'StringLiteral' || (el.type === 'Literal' && typeof el.value === 'string')) {
67
- items.push({ type: 'string', value: String(el.value) });
68
- } else if (el.type === 'ObjectExpression') {
69
- const obj = objectLiteralToPlain(el);
70
- if (obj) items.push({ type: 'object', value: obj });
71
- else allLiteral = false;
72
- } else {
73
- allLiteral = false;
80
+ const inner = unwrapTypeCasts(el);
81
+ if (inner.type === 'StringLiteral' || (inner.type === 'Literal' && typeof inner.value === 'string')) {
82
+ items.push({ type: 'string', value: String(inner.value) });
83
+ } else if (inner.type === 'ObjectExpression') {
84
+ const obj = objectLiteralToPlain(inner);
85
+ if (obj && Object.keys(obj).length > 0) {
86
+ items.push({ type: 'object', value: obj });
87
+ }
74
88
  }
75
89
  }
76
- if (allLiteral && items.length) bindings.set(node.id.name, { kind: 'array', items });
90
+ if (items.length > 0) {
91
+ bindings.set(node.id.name, { kind: 'array', items });
92
+ }
77
93
  }
78
- if (node.init.type === 'ObjectExpression') {
79
- const obj = objectLiteralToPlain(node.init);
80
- if (obj) bindings.set(node.id.name, { kind: 'object', value: obj });
94
+ if (init.type === 'ObjectExpression') {
95
+ const obj = objectLiteralToPlain(init);
96
+ if (obj && Object.keys(obj).length > 0) {
97
+ bindings.set(node.id.name, { kind: 'object', value: obj });
98
+ }
81
99
  }
82
100
  }
83
101
  this.traverse(pathNode);
@@ -90,18 +108,57 @@ function objectLiteralToPlain(node) {
90
108
  if (!node || node.type !== 'ObjectExpression') return null;
91
109
  const out = {};
92
110
  for (const prop of node.properties || []) {
93
- if (prop.type !== 'ObjectProperty' && prop.type !== 'Property') return null;
111
+ if (prop.type !== 'ObjectProperty' && prop.type !== 'Property') continue;
94
112
  const key = prop.key && (prop.key.name || prop.key.value);
95
- if (!key || prop.computed) return null;
96
- const val = prop.value;
113
+ if (!key || prop.computed) continue;
114
+ const val = unwrapTypeCasts(prop.value);
115
+ if (!val) continue;
116
+
97
117
  if (val.type === 'StringLiteral' || (val.type === 'Literal' && typeof val.value === 'string')) {
98
118
  out[key] = String(val.value);
99
119
  } else if (val.type === 'NumericLiteral' || (val.type === 'Literal' && typeof val.value === 'number')) {
100
120
  out[key] = val.value;
101
121
  } else if (val.type === 'BooleanLiteral' || (val.type === 'Literal' && typeof val.value === 'boolean')) {
102
122
  out[key] = val.value;
103
- } else {
104
- return null;
123
+ } else if (val.type === 'NullLiteral' || (val.type === 'Literal' && val.value === null)) {
124
+ out[key] = null;
125
+ } else if (val.type === 'TemplateLiteral' && (!val.expressions || val.expressions.length === 0)) {
126
+ out[key] = val.quasis?.map((q) => q.value?.cooked || q.value?.raw || '').join('') || '';
127
+ } else if (val.type === 'Identifier') {
128
+ if (/^[A-Z]/.test(val.name)) {
129
+ // Component references (e.g. icon: Truck, Icon: ShieldCheck) are not merchant content
130
+ return null;
131
+ }
132
+ out[key] = val.name;
133
+ } else if (val.type === 'UnaryExpression' && val.argument) {
134
+ if (val.operator === '-' && (val.argument.type === 'NumericLiteral' || typeof val.argument.value === 'number')) {
135
+ out[key] = -val.argument.value;
136
+ } else if (val.operator === '!' && (val.argument.type === 'BooleanLiteral' || typeof val.argument.value === 'boolean')) {
137
+ out[key] = !val.argument.value;
138
+ }
139
+ } else if (val.type === 'ArrayExpression') {
140
+ const arr = [];
141
+ for (const el of val.elements || []) {
142
+ if (!el) continue;
143
+ const inner = unwrapTypeCasts(el);
144
+ if (!inner) continue;
145
+ if (inner.type === 'StringLiteral' || (inner.type === 'Literal' && typeof inner.value === 'string')) {
146
+ arr.push(String(inner.value));
147
+ } else if (inner.type === 'NumericLiteral' || (inner.type === 'Literal' && typeof inner.value === 'number')) {
148
+ arr.push(inner.value);
149
+ } else if (inner.type === 'BooleanLiteral' || (inner.type === 'Literal' && typeof inner.value === 'boolean')) {
150
+ arr.push(inner.value);
151
+ } else if (inner.type === 'ObjectExpression') {
152
+ const nestedObj = objectLiteralToPlain(inner);
153
+ if (nestedObj) arr.push(nestedObj);
154
+ } else if (inner.type === 'Identifier') {
155
+ arr.push(inner.name);
156
+ }
157
+ }
158
+ out[key] = arr;
159
+ } else if (val.type === 'ObjectExpression') {
160
+ const nestedObj = objectLiteralToPlain(val);
161
+ if (nestedObj) out[key] = nestedObj;
105
162
  }
106
163
  }
107
164
  return out;
@@ -262,16 +319,47 @@ function collectItemFieldUsage(callback, itemParam) {
262
319
  usage.set(property, role);
263
320
  }
264
321
 
265
- function memberProperty(expr) {
266
- if (!expr || expr.type !== 'MemberExpression' || expr.computed) return null;
267
- if (expr.object?.type !== 'Identifier' || expr.object.name !== itemParam) return null;
268
- return expr.property?.name || null;
322
+ function findItemMemberProperties(expr) {
323
+ const props = [];
324
+ if (!expr) return props;
325
+ if (expr.type === 'MemberExpression' && !expr.computed) {
326
+ if (expr.object?.type === 'Identifier' && expr.object.name === itemParam) {
327
+ if (expr.property?.name) props.push(expr.property.name);
328
+ } else if (expr.object?.type === 'MemberExpression') {
329
+ const sub = findItemMemberProperties(expr.object);
330
+ if (sub.length > 0 && expr.property?.name) {
331
+ props.push(expr.property.name);
332
+ }
333
+ }
334
+ } else if (expr.type === 'LogicalExpression' || expr.type === 'BinaryExpression') {
335
+ props.push(...findItemMemberProperties(expr.left));
336
+ props.push(...findItemMemberProperties(expr.right));
337
+ } else if (expr.type === 'ConditionalExpression') {
338
+ props.push(...findItemMemberProperties(expr.test));
339
+ props.push(...findItemMemberProperties(expr.consequent));
340
+ props.push(...findItemMemberProperties(expr.alternate));
341
+ } else if (expr.type === 'TemplateLiteral') {
342
+ for (const sub of expr.expressions || []) {
343
+ props.push(...findItemMemberProperties(sub));
344
+ }
345
+ }
346
+ return props;
269
347
  }
270
348
 
349
+ let usesItemAsComponent = false;
271
350
  recast.types.visit(callback, {
351
+ visitJSXOpeningElement(pathNode) {
352
+ const name = pathNode.node.name;
353
+ if (name?.type === 'JSXMemberExpression') {
354
+ if (name.object?.type === 'Identifier' && name.object.name === itemParam) {
355
+ usesItemAsComponent = true;
356
+ }
357
+ }
358
+ this.traverse(pathNode);
359
+ },
272
360
  visitJSXExpressionContainer(pathNode) {
273
- const property = memberProperty(pathNode.node.expression);
274
- if (property) {
361
+ const properties = findItemMemberProperties(pathNode.node.expression);
362
+ for (const property of properties) {
275
363
  const parent = pathNode.parent?.node || pathNode.parent?.value;
276
364
  if (parent?.type === 'JSXAttribute') {
277
365
  const attribute = parent.name?.name;
@@ -290,7 +378,7 @@ function collectItemFieldUsage(callback, itemParam) {
290
378
  },
291
379
  });
292
380
 
293
- return usage;
381
+ return { usage, usesItemAsComponent };
294
382
  }
295
383
 
296
384
  function classNameOf(node) {
@@ -592,13 +680,14 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
592
680
  bindings.get(mapInfo.objectName)?.kind === 'array'
593
681
  ) {
594
682
  const arr = bindings.get(mapInfo.objectName);
595
- const itemUsage = collectItemFieldUsage(mapInfo.callback, mapInfo.itemParam);
683
+ const { usage: itemUsage, usesItemAsComponent } = collectItemFieldUsage(mapInfo.callback, mapInfo.itemParam);
596
684
  const objectItems = arr.items.every((item) => item.type === 'object');
597
685
  const boundProperties = [...itemUsage.keys()];
598
686
  const convertible =
687
+ !usesItemAsComponent &&
599
688
  objectItems &&
600
689
  boundProperties.length > 0 &&
601
- arr.items.every((item) => boundProperties.every((key) => key in item.value));
690
+ arr.items.some((item) => boundProperties.some((key) => key in item.value));
602
691
 
603
692
  candidates.push({
604
693
  ...baseMeta,
@@ -612,11 +701,18 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
612
701
  indexParam: mapInfo.indexParam,
613
702
  itemFields: [...itemUsage.entries()].map(([key, role]) => ({ key, role })),
614
703
  objectItems,
704
+ hasComponentRef: usesItemAsComponent,
615
705
  },
616
706
  confidence: convertible
617
707
  ? confidenceFor('collection', { staticCollection: true })
618
- : 0.3,
619
- reason: convertible ? 'static-array-map' : 'collection-shape-not-uniform',
708
+ : usesItemAsComponent
709
+ ? 0.2
710
+ : 0.82,
711
+ reason: usesItemAsComponent
712
+ ? 'collection-holds-component-ref'
713
+ : convertible
714
+ ? 'static-array-map'
715
+ : 'collection-shape-partial',
620
716
  fingerprint: fingerprintCandidate({ tag: name, kind: 'collection', size: arr.items.length }),
621
717
  });
622
718
  }
@@ -24,6 +24,7 @@ const {
24
24
  b,
25
25
  } = require('./ast.cjs');
26
26
  const { toPosix } = require('./fs-utils.cjs');
27
+ const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
27
28
 
28
29
  function findElementByLoc(ast, loc) {
29
30
  let found = null;
@@ -222,6 +223,11 @@ function applyTransformToElement(pathNode, transform) {
222
223
  return;
223
224
  }
224
225
  if (transform.operation === 'extract-text') {
226
+ const tagName = getJsxName(node);
227
+ if (BROAD_CONTENT_CONTAINERS.has(tagName)) {
228
+ wrapLiteralTextChildren(node, transform.field, transform.fallback);
229
+ return;
230
+ }
225
231
  ensurePreviewPath(node, transform.field);
226
232
  ensureStyleAttrs(node, transform.field, inferButtonKind(transform.tag));
227
233
  replaceTextChildren(node, transform.field, transform.fallback, transform.fieldType || 'text');
@@ -851,7 +857,7 @@ function instrumentLayoutSource(code, siteDataImport, providerImport = '@deneb-u
851
857
  ensureDefaultImport(ast, siteDataImport, jsonIdent);
852
858
  }
853
859
 
854
- const hasProvider = /SiteDataProvider|DenebDataProvider/.test(code);
860
+ const hasProvider = /SiteDataProvider|DenebDataProvider|<Providers\b/.test(code);
855
861
  let wrapped = hasProvider;
856
862
 
857
863
  if (!hasProvider) {