@deneb-ui/cli 2.0.48 → 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 +15 -5
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +340 -0
- package/src/arc/adapters.cjs +5 -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/index.cjs +28 -1
- package/src/arc/learning.cjs +30 -0
- package/src/arc/planner.cjs +10 -2
- package/src/arc/printer.cjs +20 -0
- package/src/arc/semantic.cjs +129 -33
- package/src/arc/transformer.cjs +7 -1
package/bin/index.js
CHANGED
|
@@ -233,8 +233,15 @@ function detectPages(projectDir) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
const
|
|
237
|
-
|
|
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.
|
|
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.
|
|
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
|
+
|
package/src/arc/adapters.cjs
CHANGED
|
@@ -193,7 +193,11 @@ function classifyActionIntent(text, href) {
|
|
|
193
193
|
const h = String(href || '').trim().toLowerCase();
|
|
194
194
|
|
|
195
195
|
// 1. WhatsApp
|
|
196
|
-
if (
|
|
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',
|
package/src/arc/ai-agent.cjs
CHANGED
|
@@ -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
|
+
};
|
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/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
|
-
|
|
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 = {}) {
|
package/src/arc/learning.cjs
CHANGED
|
@@ -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,
|
package/src/arc/planner.cjs
CHANGED
|
@@ -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.
|
|
181
|
-
|
|
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,
|
package/src/arc/printer.cjs
CHANGED
|
@@ -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,
|
package/src/arc/semantic.cjs
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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 (
|
|
76
|
+
if (init.type === 'ArrayExpression') {
|
|
62
77
|
const items = [];
|
|
63
|
-
|
|
64
|
-
for (const el of node.init.elements || []) {
|
|
78
|
+
for (const el of init.elements || []) {
|
|
65
79
|
if (!el) continue;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 (
|
|
90
|
+
if (items.length > 0) {
|
|
91
|
+
bindings.set(node.id.name, { kind: 'array', items });
|
|
92
|
+
}
|
|
77
93
|
}
|
|
78
|
-
if (
|
|
79
|
-
const obj = objectLiteralToPlain(
|
|
80
|
-
if (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')
|
|
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)
|
|
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
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
if (expr
|
|
268
|
-
|
|
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
|
|
274
|
-
|
|
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.
|
|
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
|
-
:
|
|
619
|
-
|
|
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
|
}
|
package/src/arc/transformer.cjs
CHANGED
|
@@ -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) {
|