@deneb-ui/cli 2.0.83 → 2.0.85

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
@@ -112,19 +112,47 @@ function isForbiddenFile(filename) {
112
112
  );
113
113
  }
114
114
 
115
- function stripAnsi(str) {
116
- return str.replace(/\x1b\[[0-9;]*m/g, '');
115
+ function getVisualWidth(str) {
116
+ if (!str) return 0;
117
+ const stripped = String(str).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
118
+ let width = 0;
119
+ for (const char of stripped) {
120
+ const code = char.codePointAt(0);
121
+ if (
122
+ (code >= 0x1100 && code <= 0x115F) ||
123
+ (code >= 0x2E80 && code <= 0xA4CF) ||
124
+ (code >= 0xAC00 && code <= 0xD7A3) ||
125
+ (code >= 0xF900 && code <= 0xFAFF) ||
126
+ (code >= 0xFE10 && code <= 0xFE19) ||
127
+ (code >= 0xFE30 && code <= 0xFE6F) ||
128
+ (code >= 0xFF00 && code <= 0xFF60) ||
129
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
130
+ (code >= 0x1F000 && code <= 0x1FAFF) ||
131
+ (code >= 0x2600 && code <= 0x27BF)
132
+ ) {
133
+ width += 2;
134
+ } else if (code > 0xFFFF) {
135
+ width += 2;
136
+ } else {
137
+ width += 1;
138
+ }
139
+ }
140
+ return width;
117
141
  }
118
142
 
119
- function createBox(lines, width = 55) {
143
+ function createBox(lines, preferredWidth = 58) {
120
144
  const cyan = '\x1b[36m';
121
145
  const reset = '\x1b[0m';
122
- const top = ` ${cyan}╔${'═'.repeat(width)}╗${reset}`;
123
- const bottom = ` ${cyan}╚${'═'.repeat(width)}╝${reset}`;
146
+ const visualLengths = lines.map((l) => getVisualWidth(l));
147
+ const maxLineVisual = Math.max(...visualLengths, 0);
148
+ const boxWidth = Math.max(preferredWidth, maxLineVisual + 4);
124
149
 
125
- const rows = lines.map((line) => {
126
- const rawLen = stripAnsi(line).length;
127
- const padTotal = Math.max(0, width - rawLen);
150
+ const top = ` ${cyan}╔${'═'.repeat(boxWidth)}╗${reset}`;
151
+ const bottom = ` ${cyan}╚${'═'.repeat(boxWidth)}╝${reset}`;
152
+
153
+ const rows = lines.map((line, idx) => {
154
+ const rawLen = visualLengths[idx];
155
+ const padTotal = Math.max(0, boxWidth - rawLen);
128
156
  const padLeft = Math.floor(padTotal / 2);
129
157
  const padRight = padTotal - padLeft;
130
158
  return ` ${cyan}║${reset}${' '.repeat(padLeft)}${line}${' '.repeat(padRight)}${cyan}║${reset}`;
@@ -1210,11 +1238,16 @@ async function initProject(targetInput, options = {}) {
1210
1238
  }
1211
1239
  }
1212
1240
 
1213
- if (!skipInstall) {
1241
+ if (!skipInstall && !process.env.DENEB_SKIP_FONTS) {
1214
1242
  try {
1215
1243
  const { runFontsInstall } = require('../src/tools/deneb-fonts.cjs');
1244
+ const plannedFontArgs = [];
1245
+ const fontList = conversionRes && Array.isArray(conversionRes.fontIds) ? conversionRes.fontIds : [];
1246
+ for (const id of fontList) {
1247
+ plannedFontArgs.push('--font', id);
1248
+ }
1216
1249
  console.log(`\n\x1b[36mAa Downloading and configuring DENEB Google Fonts...\x1b[0m`);
1217
- runFontsInstall(targetDir, []);
1250
+ runFontsInstall(targetDir, plannedFontArgs);
1218
1251
  } catch (err) {
1219
1252
  console.log(`\n\x1b[33m! Fonts were not installed automatically: ${err.message}. Run \x1b[1mdeneb fonts install .\x1b[0m\x1b[0m`);
1220
1253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.83",
3
+ "version": "2.0.85",
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.83",
52
+ "@deneb-ui/core": "^2.0.85",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -6,6 +6,8 @@ const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
8
 
9
+ process.env.DENEB_HOME = path.join(os.tmpdir(), 'deneb-test-home-' + Date.now());
10
+
9
11
  const { scanProject, buildDependencyGraph } = require('../scanner.cjs');
10
12
  const { analyzeFile } = require('../semantic.cjs');
11
13
  const { planTransformations } = require('../planner.cjs');
@@ -1708,8 +1710,112 @@ test('manifest buildSiteDataAndManifest creates default dual-mode palette with d
1708
1710
  assert.equal(bundle.siteData.theme.dark.textColor, '#f9fafb');
1709
1711
  });
1710
1712
 
1713
+ test('healEmptyStateConditionals preserves modal, null-check, and state-dependent conditional guards', () => {
1714
+ const { parseSource, printSource } = require('../ast.cjs');
1715
+ const { healEmptyStateConditionals } = require('../transformer.cjs');
1716
+ const code = `
1717
+ export function Showcase() {
1718
+ const [lightboxIndex, setLightboxIndex] = useState(null);
1719
+ const [selectedDish, setSelectedDish] = useState(null);
1720
+ return (
1721
+ <div>
1722
+ <AnimatePresence>
1723
+ {lightboxIndex !== null && filteredItems[lightboxIndex] && (
1724
+ <div className="fixed inset-0 z-50">
1725
+ <span data-preview-field-path="home.ofLabel">Caption</span>
1726
+ </div>
1727
+ )}
1728
+ </AnimatePresence>
1729
+ <AnimatePresence>
1730
+ {selectedDish && (
1731
+ <div className="fixed inset-0 z-50">
1732
+ <h3 data-preview-field-path="home.dishLabel">{selectedDish.name}</h3>
1733
+ </div>
1734
+ )}
1735
+ </AnimatePresence>
1736
+ </div>
1737
+ );
1738
+ }
1739
+ `;
1740
+ const ast = parseSource(code, 'src/components/Showcase.tsx');
1741
+ const healed = healEmptyStateConditionals(ast);
1742
+ const result = printSource(ast, code);
1743
+ assert.equal(healed, 0, 'No modal guards should be stripped');
1744
+ assert.match(result, /lightboxIndex !== null/);
1745
+ assert.match(result, /selectedDish &&/);
1746
+ });
1747
+
1748
+ test('M1: auditFivoraContract flags uncovered visible text as a contract violation', () => {
1749
+ const { auditFivoraContract } = require('../index.cjs');
1750
+ const profile = { routes: [{ id: 'home', file: 'src/app/page.tsx' }] };
1751
+ const siteData = { content: { home: { heroTitle: 'Welcome' } } };
1752
+ const manifest = {
1753
+ pages: [{ id: 'home', route: '/' }],
1754
+ editorSchema: {
1755
+ sections: [
1756
+ {
1757
+ id: 'home',
1758
+ path: 'home',
1759
+ type: 'object',
1760
+ fields: [{ key: 'heroTitle', type: 'text' }],
1761
+ },
1762
+ ],
1763
+ },
1764
+ };
1765
+ const inventory = {
1766
+ sources: [
1767
+ {
1768
+ rel: 'src/app/page.tsx',
1769
+ code: 'export default function Page() { return <div><h1><span data-preview-field-path="home.heroTitle">Welcome</span></h1><p>Uncovered paragraph copy here</p></div>; }',
1770
+ },
1771
+ ],
1772
+ pageKeysByFile: { 'src/app/page.tsx': ['home'] },
1773
+ };
1774
+
1775
+ const audit = auditFivoraContract({ profile, siteData, manifest, inventory });
1776
+ assert.equal(audit.passed, false, 'Audit must fail when uncovered visible text exists');
1777
+ assert.ok(audit.uncoveredVisibleText.length > 0, 'Uncovered visible text must be recorded');
1778
+ assert.match(audit.uncoveredVisibleText[0].text, /Uncovered paragraph copy here/);
1779
+ });
1780
+
1781
+ test('M2: ensureStyleAttrs is idempotent and does not duplicate style attributes', () => {
1782
+ const { parseSource, printSource, ensureStyleAttrs } = require('../ast.cjs');
1783
+ const code = '<span data-preview-field-path="home.title">Hello</span>';
1784
+ const ast = parseSource(code, 'test.tsx');
1785
+ const node = ast.program.body[0].expression;
1711
1786
 
1787
+ ensureStyleAttrs(node, 'home.title', 'text');
1788
+ const firstPass = printSource(ast, code);
1789
+ assert.match(firstPass, /data-preview-style-target="home\.title"/);
1790
+ assert.match(firstPass, /data-preview-style-type="text"/);
1712
1791
 
1792
+ // Second pass: must not duplicate attributes
1793
+ ensureStyleAttrs(node, 'home.title', 'text');
1794
+ const secondPass = printSource(ast, code);
1795
+ assert.equal(secondPass, firstPass, 'Second call must be strictly idempotent');
1796
+ });
1713
1797
 
1798
+ test('M3: collectFontIdsFromSiteData resolves planned fonts and theme accurately', () => {
1799
+ const { collectFontIdsFromSiteData, applyFontTheme } = require('../font-plan.cjs');
1800
+ const siteData = {
1801
+ theme: {
1802
+ headingFont: 'Playfair Display',
1803
+ bodyFont: 'Plus Jakarta Sans',
1804
+ },
1805
+ styles: {
1806
+ 'home.accent': { fontFamily: 'Space Grotesk' },
1807
+ },
1808
+ };
1714
1809
 
1810
+ const fontIds = collectFontIdsFromSiteData(siteData);
1811
+ assert.ok(Array.isArray(fontIds));
1812
+ assert.ok(fontIds.includes('playfair-display'));
1813
+ assert.ok(fontIds.includes('plus-jakarta-sans'));
1814
+ assert.ok(fontIds.includes('space-grotesk'));
1815
+
1816
+ const siteDataEmpty = {};
1817
+ applyFontTheme(siteDataEmpty, ['inter', 'playfair-display']);
1818
+ assert.equal(siteDataEmpty.theme.headingFont, 'Playfair Display');
1819
+ assert.equal(siteDataEmpty.theme.bodyFont, 'Inter');
1820
+ });
1715
1821
 
@@ -119,6 +119,9 @@ function rel(projectDir, filePath) {
119
119
  }
120
120
 
121
121
  function homeDenebDir() {
122
+ if (process.env.DENEB_HOME) {
123
+ return path.resolve(process.env.DENEB_HOME);
124
+ }
122
125
  return path.join(os.homedir(), '.deneb');
123
126
  }
124
127
 
package/src/arc/index.cjs CHANGED
@@ -21,6 +21,7 @@ const { applyFilePlan, instrumentLayoutSource, instrumentPageKey, resolveSiteDat
21
21
  const { applyResidualPass } = require('./residual.cjs');
22
22
  const { parseSource } = require('./ast.cjs');
23
23
  const { buildSiteDataAndManifest, writeDataBank, loadExistingData, countSchemaFields } = require('./manifest.cjs');
24
+ const { collectFontIdsFromSiteData } = require('./font-plan.cjs');
24
25
  const { validateAstFiles, validateContracts, designPreservationScore, coverageMetrics } = require('./validator.cjs');
25
26
  const { recordExperience, registryArchitecture } = require('./learning.cjs');
26
27
  const { matchRecipeV2 } = require('./recipes-v2.cjs');
@@ -613,6 +614,23 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
613
614
  }
614
615
  }
615
616
 
617
+ // Mandatory Post-Pass: Guarantee all files referencing siteData have useSiteData hook injected
618
+ const { healMissingSiteDataHooks } = require('./transformer.cjs');
619
+ for (const relativeFile of profile.jsxFiles || []) {
620
+ const abs = path.join(projectDir, relativeFile);
621
+ if (!fs.existsSync(abs)) continue;
622
+ const currentCode = fs.readFileSync(abs, 'utf8');
623
+ if (currentCode.includes('siteData')) {
624
+ const healed = healMissingSiteDataHooks(currentCode, relativeFile);
625
+ if (healed && healed !== currentCode) {
626
+ backupFile(projectDir, backupDir, abs);
627
+ fs.writeFileSync(abs, healed, 'utf8');
628
+ afterFiles[relativeFile] = healed;
629
+ if (!changedFiles.includes(relativeFile)) changedFiles.push(relativeFile);
630
+ }
631
+ }
632
+ }
633
+
616
634
  if (profile.framework === 'nextjs') {
617
635
  const before = findNextConfig(projectDir);
618
636
  if (before) backupFile(projectDir, backupDir, before.abs);
@@ -682,13 +700,39 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
682
700
  const alreadyEditable = analyses.filter((a) => a.alreadyEditable).length;
683
701
  const skippedDynamic = plan.skipped.filter((s) => /dynamic|api/.test(s.reason || '')).length;
684
702
 
685
- const fivoraAudit = auditFivoraContract({
703
+ let fivoraAudit = auditFivoraContract({
686
704
  profile,
687
705
  siteData: dataBundle.siteData,
688
706
  manifest: dataBundle.manifest,
689
707
  inventory: collectMarkerInventory(projectDir, profile, graph),
690
708
  });
691
709
 
710
+ // Auto-reconciliation: If fivoraAudit flagged unknown paths, auto-register them to controlOnlyPaths
711
+ if (!fivoraAudit.passed && fivoraAudit.errors?.length > 0) {
712
+ let manifestModified = false;
713
+ for (const err of fivoraAudit.errors) {
714
+ const unknownMatch = err.match(/references unknown path "([^"]+)"/);
715
+ if (unknownMatch) {
716
+ const unknownPath = unknownMatch[1];
717
+ dataBundle.manifest.visualEditing = dataBundle.manifest.visualEditing || {};
718
+ dataBundle.manifest.visualEditing.controlOnlyPaths = dataBundle.manifest.visualEditing.controlOnlyPaths || [];
719
+ if (!dataBundle.manifest.visualEditing.controlOnlyPaths.includes(unknownPath)) {
720
+ dataBundle.manifest.visualEditing.controlOnlyPaths.push(unknownPath);
721
+ manifestModified = true;
722
+ }
723
+ }
724
+ }
725
+ if (manifestModified) {
726
+ writeDataBank(projectDir, dataBundle.siteData, dataBundle.manifest);
727
+ fivoraAudit = auditFivoraContract({
728
+ profile,
729
+ siteData: dataBundle.siteData,
730
+ manifest: dataBundle.manifest,
731
+ inventory: collectMarkerInventory(projectDir, profile, graph),
732
+ });
733
+ }
734
+ }
735
+
692
736
  const coverage = coverageMetrics({
693
737
  analyses,
694
738
  plan,
@@ -742,7 +786,11 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
742
786
  }
743
787
  }
744
788
  outcome = 'rolled-back';
745
- printer.printRollback('Strict Fivora contract failed');
789
+ const reasons = [];
790
+ if (!fivoraAudit.passed) reasons.push(`${fivoraAudit.errors.length} Fivora contract error(s)`);
791
+ if (fivoraAudit.uncoveredVisibleText.length > 0) reasons.push(`${fivoraAudit.uncoveredVisibleText.length} uncovered visible text node(s)`);
792
+ if (designFailure) reasons.push(`Design preservation score below threshold (${design.score} < 98)`);
793
+ printer.printRollback(`Strict Fivora contract failed: ${reasons.join(', ')}`);
746
794
  } else if (contractFailure) {
747
795
  outcome = 'contract-failed';
748
796
  } else if (designFailure) {
@@ -819,6 +867,7 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
819
867
  designPreservation: design.score,
820
868
  validation,
821
869
  outcome,
870
+ fontIds: collectFontIdsFromSiteData(dataBundle.siteData),
822
871
  };
823
872
 
824
873
  const report = buildReport({
@@ -911,6 +960,7 @@ module.exports = {
911
960
  runDenebArc,
912
961
  parseArcOptions,
913
962
  scanProject,
963
+ auditFivoraContract,
914
964
  ENGINE_ID,
915
965
  ARC_VERSION,
916
966
  };
@@ -108,6 +108,10 @@ const BASELINE_VERIFIED_FINGERPRINTS = {
108
108
  'empty-state-array-guard': { id: 'empty-state-array-guard', state: 'verified', successfulApplications: 50, failedApplications: 0 },
109
109
  'action-label-split': { id: 'action-label-split', state: 'verified', successfulApplications: 50, failedApplications: 0 },
110
110
  'section-overflow-clip': { id: 'section-overflow-clip', state: 'verified', successfulApplications: 50, failedApplications: 0 },
111
+ 'auto-injected-site-data-hook': { id: 'auto-injected-site-data-hook', state: 'verified', successfulApplications: 50, failedApplications: 0 },
112
+ 'hidden-preview-marker-strip': { id: 'hidden-preview-marker-strip', state: 'verified', successfulApplications: 50, failedApplications: 0 },
113
+ 'unknown-marker-auto-prune': { id: 'unknown-marker-auto-prune', state: 'verified', successfulApplications: 50, failedApplications: 0 },
114
+ 'hidden-element-strict-contract': { id: 'hidden-element-strict-contract', state: 'deprecated', successfulApplications: 0, failedApplications: 50 },
111
115
  };
112
116
 
113
117
  function loadFingerprintBoost(fingerprint) {
@@ -427,8 +427,16 @@ function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, r
427
427
  return { code, changed: false, fields: [], applied: 0 };
428
428
  }
429
429
 
430
+ let finalCode = printSource(ast, code);
431
+ if (finalCode.includes('siteData')) {
432
+ try {
433
+ const { healMissingSiteDataHooks } = require('./transformer.cjs');
434
+ finalCode = healMissingSiteDataHooks(finalCode, file);
435
+ } catch {}
436
+ }
437
+
430
438
  return {
431
- code: printSource(ast, code),
439
+ code: finalCode,
432
440
  changed: true,
433
441
  fields,
434
442
  applied,
@@ -286,6 +286,34 @@ function hasStaticMarker(node) {
286
286
  return hasJsxAttribute(node, 'data-preview-static');
287
287
  }
288
288
 
289
+ function isElementOrParentHidden(pathNode) {
290
+ let current = pathNode;
291
+ while (current) {
292
+ const node = current.node || current.value || current;
293
+ if (node && (node.type === 'JSXElement' || node.type === 'JSXOpeningElement')) {
294
+ const attrs = (node.type === 'JSXElement' ? node.openingElement?.attributes : node.attributes) || [];
295
+ if (attrs.some((a) => a.type === 'JSXAttribute' && a.name && (
296
+ a.name.name === 'hidden' ||
297
+ (a.name.name === 'aria-hidden' && /true/.test(recast.print(a).code))
298
+ ))) {
299
+ return true;
300
+ }
301
+ const classAttr = attrs.find((a) => a.type === 'JSXAttribute' && a.name && (a.name.name === 'className' || a.name.name === 'class'));
302
+ if (classAttr) {
303
+ const rawClass = recast.print(classAttr).code;
304
+ if (/(?<![\w-])hidden(?![a-zA-Z0-9_-])/.test(rawClass)) return true;
305
+ }
306
+ const styleAttr = attrs.find((a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'style');
307
+ if (styleAttr) {
308
+ const rawStyle = recast.print(styleAttr).code;
309
+ if (/(?:display\s*:\s*['"]none['"]|visibility\s*:\s*['"]hidden['"])/.test(rawStyle)) return true;
310
+ }
311
+ }
312
+ current = current.parentPath || current.parent;
313
+ }
314
+ return false;
315
+ }
316
+
289
317
  function parentNames(pathNode) {
290
318
  const names = [];
291
319
  let current = pathNode.parent;
@@ -523,6 +551,21 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
523
551
  return;
524
552
  }
525
553
 
554
+ if (isElementOrParentHidden(pathNode)) {
555
+ candidates.push({
556
+ loc,
557
+ tag: name,
558
+ kind: 'decoration',
559
+ confidence: 0.1,
560
+ skip: true,
561
+ reason: 'hidden-element-strict-contract',
562
+ file: relativeFile,
563
+ ownerScope,
564
+ });
565
+ this.traverse(pathNode);
566
+ return;
567
+ }
568
+
526
569
  const importSource = imports[name.split('.')[0]];
527
570
  const recognition = recognizeWithAdapters(node, { profile, imports }, adapters);
528
571
  const mapInfo = inMapCallback(pathNode);
@@ -1537,7 +1537,7 @@ function sanitizeContradictoryMarkers(ast) {
1537
1537
  }
1538
1538
 
1539
1539
  function sanitizeContradictoryMarkersInSource(code, relativeFile) {
1540
- if (!code.includes('data-preview-static') && !code.includes('data-preview-field-path') && !code.includes('overflow-hidden')) {
1540
+ if (!code.includes('data-preview-static') && !code.includes('data-preview-field-path') && !code.includes('data-preview-list-path') && !code.includes('data-preview-item-path') && !code.includes('overflow-hidden') && !code.includes('hidden')) {
1541
1541
  return { code, updated: false };
1542
1542
  }
1543
1543
  let ast;
@@ -1624,6 +1624,108 @@ function findFirstLeafChild(node) {
1624
1624
  return null;
1625
1625
  }
1626
1626
 
1627
+ function isUnsafeToStripCondition(leftExpr, rightExpr, pathNode) {
1628
+ if (!leftExpr || !rightExpr) return true;
1629
+
1630
+ // 1. Collect all identifiers used on the left-hand side
1631
+ const leftIdentifiers = new Set();
1632
+ recast.types.visit(leftExpr, {
1633
+ visitIdentifier(idPath) {
1634
+ leftIdentifiers.add(idPath.node.name);
1635
+ return false;
1636
+ },
1637
+ });
1638
+
1639
+ if (leftIdentifiers.size === 0) return true;
1640
+
1641
+ // 2. State & modal variable keywords (e.g., selectedDish, lightboxIndex, isOpen, activeTab)
1642
+ const stateKeywordRegex = /(?:open|active|selected|current|expanded|visible|show|modal|dialog|lightbox|drawer|loading|tab|filter|step|index)/i;
1643
+ for (const id of leftIdentifiers) {
1644
+ if (stateKeywordRegex.test(id)) {
1645
+ return true; // NEVER strip modal/state/selection guards
1646
+ }
1647
+ }
1648
+
1649
+ // 3. Null / undefined / equality / comparison checks
1650
+ let hasComparisonOrNullCheck = false;
1651
+ recast.types.visit(leftExpr, {
1652
+ visitBinaryExpression(bPath) {
1653
+ const op = bPath.node.operator;
1654
+ if (['!==', '!=', '===', '==', '>', '<', '>=', '<='].includes(op)) {
1655
+ hasComparisonOrNullCheck = true;
1656
+ }
1657
+ this.traverse(bPath);
1658
+ },
1659
+ visitUnaryExpression(uPath) {
1660
+ if (uPath.node.operator === '!') {
1661
+ hasComparisonOrNullCheck = true;
1662
+ }
1663
+ this.traverse(uPath);
1664
+ },
1665
+ });
1666
+ if (hasComparisonOrNullCheck) return true;
1667
+
1668
+ // 4. Check if ANY identifier in leftExpr is referenced inside rightExpr
1669
+ let rightReferencesLeftId = false;
1670
+ recast.types.visit(rightExpr, {
1671
+ visitIdentifier(idPath) {
1672
+ if (leftIdentifiers.has(idPath.node.name)) {
1673
+ rightReferencesLeftId = true;
1674
+ return false;
1675
+ }
1676
+ this.traverse(idPath);
1677
+ },
1678
+ });
1679
+ if (rightReferencesLeftId) return true;
1680
+
1681
+ // 5. Check if rightExpr or any of its ancestors is an interactive modal / dialog / overlay / AnimatePresence
1682
+ let curr = pathNode;
1683
+ while (curr && curr.parent) {
1684
+ const parentNode = curr.parent.node;
1685
+ if (parentNode && parentNode.type === 'JSXElement') {
1686
+ const parentTag = getJsxName(parentNode);
1687
+ if (/^(?:AnimatePresence|Dialog|Modal|Lightbox|Drawer|Popup|Sheet|Portal)$/i.test(parentTag)) {
1688
+ return true;
1689
+ }
1690
+ }
1691
+ curr = curr.parent;
1692
+ }
1693
+
1694
+ let isModalOrOverlay = false;
1695
+ recast.types.visit(rightExpr, {
1696
+ visitJSXElement(elPath) {
1697
+ const node = elPath.node;
1698
+ const tag = getJsxName(node);
1699
+ if (/^(?:AnimatePresence|Dialog|Modal|Lightbox|Drawer|Popup|Sheet|Portal)$/i.test(tag)) {
1700
+ isModalOrOverlay = true;
1701
+ return false;
1702
+ }
1703
+ const classAttr = findJsxAttribute(node, 'className') || findJsxAttribute(node, 'class');
1704
+ if (classAttr && classAttr.value) {
1705
+ const val = classAttr.value.value || '';
1706
+ if (typeof val === 'string' && (/fixed\s+inset-0/i.test(val) || /z-(?:50|\[50\]|40)/i.test(val) || /backdrop-blur/i.test(val))) {
1707
+ isModalOrOverlay = true;
1708
+ return false;
1709
+ }
1710
+ }
1711
+ const roleAttr = findJsxAttribute(node, 'role');
1712
+ if (roleAttr && roleAttr.value && /^(?:dialog|alertdialog)$/i.test(String(roleAttr.value.value || ''))) {
1713
+ isModalOrOverlay = true;
1714
+ return false;
1715
+ }
1716
+ this.traverse(elPath);
1717
+ },
1718
+ });
1719
+ if (isModalOrOverlay) return true;
1720
+
1721
+ // 6. Only allow stripping if leftExpr is purely a direct siteData content member chain
1722
+ const leftCode = recast.print(leftExpr).code;
1723
+ const isDirectContentCheck = /siteData(?:\?\.|\.)content(?:\?\.|\.)/i.test(leftCode);
1724
+ if (!isDirectContentCheck) return true;
1725
+
1726
+ return false;
1727
+ }
1728
+
1627
1729
  function healEmptyStateConditionals(ast) {
1628
1730
  let healed = 0;
1629
1731
 
@@ -1650,9 +1752,11 @@ function healEmptyStateConditionals(ast) {
1650
1752
  visitLogicalExpression(pathNode) {
1651
1753
  const expr = pathNode.node;
1652
1754
  if (expr.operator === '&&' && jsxHasPreviewMarker(expr.right)) {
1653
- pathNode.replace(expr.right);
1654
- healed++;
1655
- return false;
1755
+ if (!isUnsafeToStripCondition(expr.left, expr.right, pathNode)) {
1756
+ pathNode.replace(expr.right);
1757
+ healed++;
1758
+ return false;
1759
+ }
1656
1760
  }
1657
1761
  this.traverse(pathNode);
1658
1762
  },
@@ -1663,20 +1767,24 @@ function healEmptyStateConditionals(ast) {
1663
1767
  return;
1664
1768
  }
1665
1769
  if (expr.type === 'LogicalExpression' && expr.operator === '&&' && jsxHasPreviewMarker(expr.right)) {
1666
- pathNode.node.expression = expr.right;
1667
- healed++;
1668
- return false;
1770
+ if (!isUnsafeToStripCondition(expr.left, expr.right, pathNode)) {
1771
+ pathNode.node.expression = expr.right;
1772
+ healed++;
1773
+ return false;
1774
+ }
1669
1775
  }
1670
1776
  if (
1671
1777
  expr.type === 'ConditionalExpression' &&
1672
1778
  jsxHasPreviewMarker(expr.consequent) &&
1673
1779
  (isNullish(expr.alternate) || isNullish(expr.consequent))
1674
1780
  ) {
1675
- pathNode.node.expression = jsxHasPreviewMarker(expr.consequent) && !isNullish(expr.consequent)
1676
- ? expr.consequent
1677
- : expr.alternate;
1678
- healed++;
1679
- return false;
1781
+ if (!isUnsafeToStripCondition(expr.test, expr.consequent, pathNode)) {
1782
+ pathNode.node.expression = jsxHasPreviewMarker(expr.consequent) && !isNullish(expr.consequent)
1783
+ ? expr.consequent
1784
+ : expr.alternate;
1785
+ healed++;
1786
+ return false;
1787
+ }
1680
1788
  }
1681
1789
  this.traverse(pathNode);
1682
1790
  },
@@ -1720,10 +1828,15 @@ function healHiddenPreviewMarkers(ast) {
1720
1828
  if (expr.type === 'TemplateLiteral') classText = (expr.quasis || []).map((q) => q.value.cooked || q.value.raw || '').join(' ');
1721
1829
  }
1722
1830
  }
1723
- const hiddenAttr = attrs.some(
1724
- (attr) => attr.type === 'JSXAttribute' && attr.name && (attr.name.name === 'hidden' || (attr.name.name === 'aria-hidden' && recast.print(attr).code.includes('true')))
1725
- );
1726
- if (hiddenAttr || /(^|\s)hidden(\s|$)/.test(classText)) {
1831
+ const isHidden =
1832
+ attrs.some((attr) => attr.type === 'JSXAttribute' && attr.name && (
1833
+ attr.name.name === 'hidden' ||
1834
+ (attr.name.name === 'aria-hidden' && /true/.test(recast.print(attr).code))
1835
+ )) ||
1836
+ /(?<![\w-])hidden(?![a-zA-Z0-9_-])/.test(classText) ||
1837
+ attrs.some((attr) => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'style' && /(?:display\s*:\s*['"]none['"]|visibility\s*:\s*['"]hidden['"])/.test(recast.print(attr).code));
1838
+
1839
+ if (isHidden) {
1727
1840
  pathNode.node.attributes = attrs.filter(
1728
1841
  (attr) =>
1729
1842
  !(
@@ -1923,6 +2036,77 @@ function healMissingSiteDataHooks(code, filePath = 'file.tsx') {
1923
2036
  return code;
1924
2037
  }
1925
2038
 
2039
+ function healUnguardedModalConditionals(ast) {
2040
+ let healed = 0;
2041
+ const stateVariables = [];
2042
+ recast.types.visit(ast, {
2043
+ visitVariableDeclarator(pathNode) {
2044
+ const node = pathNode.node;
2045
+ if (node.id && node.id.type === 'ArrayPattern') {
2046
+ const first = node.id.elements[0];
2047
+ if (first && first.type === 'Identifier') {
2048
+ stateVariables.push(first.name);
2049
+ }
2050
+ }
2051
+ this.traverse(pathNode);
2052
+ },
2053
+ });
2054
+
2055
+ if (stateVariables.length === 0) return 0;
2056
+
2057
+ recast.types.visit(ast, {
2058
+ visitJSXElement(pathNode) {
2059
+ const tag = getJsxName(pathNode.node);
2060
+ if (tag === 'AnimatePresence') {
2061
+ for (const child of pathNode.node.children || []) {
2062
+ if (child.type === 'JSXExpressionContainer') {
2063
+ let inner = child.expression;
2064
+ if (inner && inner.type === 'ParenthesizedExpression') {
2065
+ inner = inner.expression;
2066
+ }
2067
+ if (inner && (inner.type === 'JSXElement' || inner.type === 'JSXFragment')) {
2068
+ const innerIds = new Set();
2069
+ recast.types.visit(inner, {
2070
+ visitIdentifier(idPath) {
2071
+ innerIds.add(idPath.node.name);
2072
+ return false;
2073
+ },
2074
+ });
2075
+
2076
+ const matchingVar = stateVariables.find((sv) => innerIds.has(sv));
2077
+ if (matchingVar) {
2078
+ let guardExpr;
2079
+ if (/index/i.test(matchingVar)) {
2080
+ guardExpr = b.binaryExpression('!==', b.identifier(matchingVar), b.nullLiteral());
2081
+ } else {
2082
+ guardExpr = b.identifier(matchingVar);
2083
+ }
2084
+ child.expression = b.logicalExpression('&&', guardExpr, inner);
2085
+ healed++;
2086
+ }
2087
+ }
2088
+ }
2089
+ }
2090
+ }
2091
+ this.traverse(pathNode);
2092
+ },
2093
+ });
2094
+
2095
+ return healed;
2096
+ }
2097
+
2098
+ function healUnguardedModalConditionalsInSource(code, filePath = 'file.tsx') {
2099
+ if (!code.includes('AnimatePresence') && !code.includes('fixed inset-0')) return { code, updated: false };
2100
+ try {
2101
+ const ast = parseSource(code, filePath);
2102
+ const healed = healUnguardedModalConditionals(ast);
2103
+ if (healed > 0) {
2104
+ return { code: printSource(ast, code), updated: true, count: healed };
2105
+ }
2106
+ } catch {}
2107
+ return { code, updated: false };
2108
+ }
2109
+
1926
2110
  module.exports = {
1927
2111
  applyFilePlan,
1928
2112
  applyCollectionTransform,
@@ -1942,6 +2126,8 @@ module.exports = {
1942
2126
  healDecorativeOverlays,
1943
2127
  healLegacyProductDetailLinks,
1944
2128
  healMissingSiteDataHooks,
2129
+ healUnguardedModalConditionals,
2130
+ healUnguardedModalConditionalsInSource,
1945
2131
  injectSiteDataHook,
1946
2132
  };
1947
2133
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  const ARC_NAME = 'Deneb ARC';
4
4
  const ARC_FULL_NAME = 'Deneb Adaptive Refactoring Compiler — AI-Augmented';
5
- const ARC_VERSION = '2.1.0';
5
+ const ARC_VERSION = '2.2.0';
6
6
  const SCHEMA_VERSION = 2;
7
7
  const ENGINE_ID = 'deneb-arc';
8
8
 
@@ -13,17 +13,49 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const { spawnSync } = require('child_process');
15
15
  const { matchRecipeForProject, loadAllRecipes } = require('./recipe-engine.cjs');
16
- const { healMissingSiteDataHooks, healBroadContainerMarkers, healSectionOverflowHidden } = require('../arc/transformer.cjs');
16
+ const { healMissingSiteDataHooks, healBroadContainerMarkers, healSectionOverflowHidden, healUnguardedModalConditionalsInSource } = require('../arc/transformer.cjs');
17
+
18
+ function getVisualWidth(str) {
19
+ if (!str) return 0;
20
+ const stripped = String(str).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
21
+ let width = 0;
22
+ for (const char of stripped) {
23
+ const code = char.codePointAt(0);
24
+ if (
25
+ (code >= 0x1100 && code <= 0x115F) ||
26
+ (code >= 0x2E80 && code <= 0xA4CF) ||
27
+ (code >= 0xAC00 && code <= 0xD7A3) ||
28
+ (code >= 0xF900 && code <= 0xFAFF) ||
29
+ (code >= 0xFE10 && code <= 0xFE19) ||
30
+ (code >= 0xFE30 && code <= 0xFE6F) ||
31
+ (code >= 0xFF00 && code <= 0xFF60) ||
32
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
33
+ (code >= 0x1F000 && code <= 0x1FAFF) ||
34
+ (code >= 0x2600 && code <= 0x27BF)
35
+ ) {
36
+ width += 2;
37
+ } else if (code > 0xFFFF) {
38
+ width += 2;
39
+ } else {
40
+ width += 1;
41
+ }
42
+ }
43
+ return width;
44
+ }
17
45
 
18
- function createBox(lines, width = 60) {
19
- const horizontal = '═'.repeat(width - 2);
46
+ function createBox(lines, preferredWidth = 66) {
47
+ const visualLengths = lines.map((l) => getVisualWidth(l));
48
+ const maxLineVisual = Math.max(...visualLengths, 0);
49
+ const boxWidth = Math.max(preferredWidth, maxLineVisual + 6);
50
+ const innerWidth = boxWidth - 4;
51
+
52
+ const horizontal = '═'.repeat(boxWidth - 2);
20
53
  const top = ` ╔${horizontal}╗`;
21
54
  const bottom = ` ╚${horizontal}╝`;
22
55
 
23
- const content = lines.map((line) => {
24
- // Strip ANSI colors for length calculation
25
- const stripped = line.replace(/\x1b\[[0-9;]*m/g, '');
26
- const padding = Math.max(0, width - 4 - stripped.length);
56
+ const content = lines.map((line, idx) => {
57
+ const vLen = visualLengths[idx];
58
+ const padding = Math.max(0, innerWidth - vLen);
27
59
  const leftPad = Math.floor(padding / 2);
28
60
  const rightPad = padding - leftPad;
29
61
  return ` ║ ${' '.repeat(leftPad)}${line}${' '.repeat(rightPad)} ║`;
@@ -201,6 +233,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
201
233
  // =========================================================================
202
234
  if (!isJson) console.log('\n\x1b[1m[2/7] Project Package Configuration:\x1b[0m');
203
235
  const suite2 = 'Package Configuration';
236
+ const sourceFiles = findSourceFiles(path.join(targetDir, 'src'));
204
237
 
205
238
  const pkgPath = path.join(targetDir, 'package.json');
206
239
  let pkg = null;
@@ -288,6 +321,32 @@ function runDoctor(targetDirInput = '.', options = {}) {
288
321
  addCheck(suite2, 'err', 'Platform Engine Compatibility', 'Detected packages requiring Node >=22. Run "deneb doctor --fix" to inject Node 20 overrides.', { code: 'DNB-ENG-001' }) //, 'Detected packages requiring Node >=22. Run "deneb doctor --fix" to inject Node 20 overrides.');
289
322
  }
290
323
 
324
+ // Pre-heal missing useSiteData hooks and unguarded modals before tsc runs when --fix is enabled
325
+ if (shouldFix) {
326
+ for (const file of sourceFiles) {
327
+ if (!/\.(tsx|jsx|ts|js)$/.test(file)) continue;
328
+ let c = fs.readFileSync(file, 'utf-8');
329
+ let modified = false;
330
+ if (c.includes('siteData')) {
331
+ const healed = healMissingSiteDataHooks(c, file);
332
+ if (healed && healed !== c) {
333
+ c = healed;
334
+ modified = true;
335
+ }
336
+ }
337
+ if (c.includes('AnimatePresence') || c.includes('fixed inset-0')) {
338
+ const modalHealed = healUnguardedModalConditionalsInSource(c, file);
339
+ if (modalHealed && modalHealed.updated) {
340
+ c = modalHealed.code;
341
+ modified = true;
342
+ }
343
+ }
344
+ if (modified) {
345
+ fs.writeFileSync(file, c, 'utf8');
346
+ }
347
+ }
348
+ }
349
+
291
350
  // Check TypeScript compilation preflight
292
351
  const tsconfigPath = path.join(targetDir, 'tsconfig.json');
293
352
  if (fs.existsSync(tsconfigPath)) {
@@ -480,7 +539,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
480
539
  }
481
540
 
482
541
  // Scan all source files for visual editing contract compliance
483
- const sourceFiles = findSourceFiles(path.join(targetDir, 'src'));
542
+ // (sourceFiles already loaded)
484
543
  const foundFieldPaths = new Set();
485
544
  const orphanPaths = [];
486
545
  const missingInSchema = [];
@@ -869,7 +928,7 @@ function runDoctor(targetDirInput = '.', options = {}) {
869
928
  for (const file of sourceFiles) {
870
929
  if (!/\.(tsx|jsx|ts|js)$/.test(file)) continue;
871
930
  let c = fs.readFileSync(file, 'utf-8');
872
- if (c.includes('siteData') && (c.includes('useSiteData') || c.includes('@deneb-ui/ui'))) {
931
+ if (c.includes('siteData')) {
873
932
  const healed = healMissingSiteDataHooks(c, file);
874
933
  const norm = (s) => s.replace(/\r\n/g, '\n');
875
934
  if (healed && norm(healed) !== norm(c)) {